@rawwee/interactive-mcp 1.0.0 → 1.2.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
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- ![Screenshot 2025-05-13 213745](https://github.com/user-attachments/assets/40208534-5910-4eb2-bfbc-58f7d93aec95)
5
+ ![Interactive MCP screenshot](docs/image.png)
6
6
 
7
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
8
 
@@ -18,6 +18,8 @@ This server exposes the following tools via the Model Context Protocol (MCP):
18
18
  - `ask_intensive_chat`: Asks a question within an active intensive chat session.
19
19
  - `stop_intensive_chat`: Closes an active intensive chat session.
20
20
 
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
+
21
23
  ## Usage Scenarios
22
24
 
23
25
  This server is ideal for scenarios where an LLM needs to interact directly with the user on their local machine, such as:
@@ -129,11 +131,10 @@ This section is primarily for developers looking to modify or contribute to the
129
131
  bun run start
130
132
  ```
131
133
 
132
- ### Terminal UI backend status
133
-
134
- `interactive-mcp` now uses OpenTUI (`@opentui/core`, `@opentui/react`) for terminal UI rendering.
134
+ ### UI backend status
135
135
 
136
- OpenTUI is the terminal UI renderer backend.
136
+ `interactive-mcp` currently runs with the OpenTUI terminal backend (`@opentui/core`, `@opentui/react`).
137
+ 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.
137
138
 
138
139
  #### Command-Line Options
139
140
 
@@ -141,7 +142,7 @@ The `interactive-mcp` server accepts the following command-line options. These s
141
142
 
142
143
  | Option | Alias | Description |
143
144
  | ----------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
- | `--timeout` | `-t` | Sets the default timeout (in seconds) for user input prompts. Defaults to 30 seconds. |
145
+ | `--timeout` | `-t` | Sets the default timeout (in seconds) for user input prompts. |
145
146
  | `--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
 
147
148
  **Example:** Setting multiple options in the client config `args` array:
@@ -108,7 +108,7 @@ async function initialize() {
108
108
  }
109
109
  }
110
110
  const App = ({ options: appOptions, onExit }) => {
111
- const { projectName, prompt, timeout, showCountdown, outputFile, heartbeatFile, predefinedOptions, searchRoot, } = appOptions;
111
+ const { prompt, timeout, showCountdown, outputFile, heartbeatFile, predefinedOptions, searchRoot, } = appOptions;
112
112
  const [timeLeft, setTimeLeft] = useState(timeout);
113
113
  const [followInput, setFollowInput] = useState(false);
114
114
  const hasCompletedRef = useRef(false);
@@ -199,9 +199,7 @@ const App = ({ options: appOptions, onExit }) => {
199
199
  const progressValue = timeout > 0 ? (timeLeft / timeout) * 100 : 0;
200
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
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 }) }))] }));
202
+ }, children: _jsx("box", { flexDirection: "column", width: "100%", paddingBottom: 1, gap: 2, children: _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
203
  };
206
204
  async function startUi() {
207
205
  await initialize();
@@ -1,9 +1,13 @@
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 path from 'node:path';
4
5
  import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
5
- import { isPrintableCharacter, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
6
+ import { isPrintableCharacter, isCopyShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
7
+ import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, } from './interactive-input/sections.js';
8
+ import { getTextareaDimensions } from './interactive-input/textarea-height.js';
6
9
  import { MarkdownText } from './MarkdownText.js';
10
+ import { copyTextToClipboard } from '../utils/clipboard.js';
7
11
  const { useKeyboard } = OpenTuiReact;
8
12
  const { useTerminalDimensions } = OpenTuiReact;
9
13
  const repositoryFileCache = new Map();
@@ -18,15 +22,48 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
18
22
  const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0);
19
23
  const [textareaRenderVersion, setTextareaRenderVersion] = useState(0);
20
24
  const [focusRequestToken, setFocusRequestToken] = useState(0);
25
+ const [clipboardStatus, setClipboardStatus] = useState(null);
21
26
  const textareaRef = useRef(null);
22
27
  const latestInputValueRef = useRef(inputValue);
23
28
  const latestCaretPositionRef = useRef(caretPosition);
24
29
  const autocompleteTargetRef = useRef(null);
25
30
  const { width, height } = useTerminalDimensions();
26
31
  const isNarrow = width < 90;
27
- const activeAutocompleteTarget = mode === 'input' ? getAutocompleteTarget(inputValue, caretPosition) : null;
32
+ const hasOptions = predefinedOptions.length > 0;
28
33
  const hasSearchRoot = Boolean(searchRoot);
34
+ const activeAutocompleteTarget = mode === 'input' ? getAutocompleteTarget(inputValue, caretPosition) : null;
35
+ const { rows: textareaRows, containerHeight: textareaContainerHeight } = useMemo(() => getTextareaDimensions({
36
+ value: inputValue,
37
+ width,
38
+ terminalHeight: height,
39
+ isNarrow,
40
+ }), [height, inputValue, isNarrow, width]);
29
41
  const textareaBaseKeyBindings = useMemo(() => textareaKeyBindings.filter((binding) => binding.action !== 'submit'), []);
42
+ const hasActiveSearchSuggestions = mode === 'input' &&
43
+ activeAutocompleteTarget !== null &&
44
+ fileSuggestions.length > 0;
45
+ const textareaBindings = useMemo(() => {
46
+ if (!hasActiveSearchSuggestions) {
47
+ return textareaBaseKeyBindings;
48
+ }
49
+ return [
50
+ ...textareaBaseKeyBindings,
51
+ { name: 'enter', action: 'submit' },
52
+ { name: 'return', action: 'submit' },
53
+ ];
54
+ }, [hasActiveSearchSuggestions, textareaBaseKeyBindings]);
55
+ const selectedSuggestion = fileSuggestions[selectedSuggestionIndex];
56
+ const selectedSuggestionVscodeLink = useMemo(() => {
57
+ if (!searchRoot || !selectedSuggestion) {
58
+ return null;
59
+ }
60
+ const absolutePath = path.resolve(searchRoot, selectedSuggestion);
61
+ const normalizedPath = absolutePath.split(path.sep).join('/');
62
+ const vscodePath = normalizedPath.startsWith('/')
63
+ ? normalizedPath
64
+ : `/${normalizedPath}`;
65
+ return `vscode://file${encodeURI(vscodePath)}`;
66
+ }, [searchRoot, selectedSuggestion]);
30
67
  const safeReadTextarea = useCallback(() => {
31
68
  const textarea = textareaRef.current;
32
69
  if (!textarea) {
@@ -80,6 +117,17 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
80
117
  useEffect(() => {
81
118
  latestCaretPositionRef.current = caretPosition;
82
119
  }, [caretPosition]);
120
+ useEffect(() => {
121
+ if (!clipboardStatus) {
122
+ return;
123
+ }
124
+ const clearStatusTimeout = setTimeout(() => {
125
+ setClipboardStatus(null);
126
+ }, 2000);
127
+ return () => {
128
+ clearTimeout(clearStatusTimeout);
129
+ };
130
+ }, [clipboardStatus]);
83
131
  useEffect(() => {
84
132
  let active = true;
85
133
  const repositoryRoot = searchRoot;
@@ -142,11 +190,11 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
142
190
  const clampedCaret = Math.max(0, Math.min(latestCaretPositionRef.current, nextValue.length));
143
191
  const didWrite = safeWriteTextarea(nextValue, clampedCaret);
144
192
  if (!didWrite) {
145
- setTextareaRenderVersion((prev) => prev + 1);
193
+ setTextareaRenderVersion((previous) => previous + 1);
146
194
  return;
147
195
  }
148
196
  if (!focusTextarea()) {
149
- setTextareaRenderVersion((prev) => prev + 1);
197
+ setTextareaRenderVersion((previous) => previous + 1);
150
198
  }
151
199
  }, [
152
200
  focusRequestToken,
@@ -174,9 +222,9 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
174
222
  }
175
223
  const nextSuggestions = rankFileSuggestions(repositoryFiles, target.query);
176
224
  setFileSuggestions(nextSuggestions);
177
- setSelectedSuggestionIndex((prev) => nextSuggestions.length === 0
225
+ setSelectedSuggestionIndex((previous) => nextSuggestions.length === 0
178
226
  ? 0
179
- : Math.min(prev, nextSuggestions.length - 1));
227
+ : Math.min(previous, nextSuggestions.length - 1));
180
228
  }, [caretPosition, inputValue, mode, repositoryFiles]);
181
229
  const syncInputStateFromTextarea = useCallback(() => {
182
230
  const textareaState = safeReadTextarea();
@@ -212,9 +260,9 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
212
260
  const requestInputFocus = useCallback((forceRemount = false) => {
213
261
  setMode('input');
214
262
  if (forceRemount) {
215
- setTextareaRenderVersion((prev) => prev + 1);
263
+ setTextareaRenderVersion((previous) => previous + 1);
216
264
  }
217
- setFocusRequestToken((prev) => prev + 1);
265
+ setFocusRequestToken((previous) => previous + 1);
218
266
  onInputActivity?.();
219
267
  }, [onInputActivity]);
220
268
  const recoverInputFocusFromClick = useCallback(() => {
@@ -227,6 +275,32 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
227
275
  setMode('option');
228
276
  onInputActivity?.();
229
277
  }, [onInputActivity, predefinedOptions.length]);
278
+ const copyInputToClipboard = useCallback(() => {
279
+ if (mode !== 'input') {
280
+ return;
281
+ }
282
+ const textarea = textareaRef.current;
283
+ const selectedText = typeof textarea?.hasSelection === 'function' &&
284
+ textarea.hasSelection() &&
285
+ typeof textarea.getSelectedText === 'function'
286
+ ? textarea.getSelectedText()
287
+ : '';
288
+ const fallbackText = textarea?.plainText ?? inputValue;
289
+ const textToCopy = selectedText.length > 0 ? selectedText : fallbackText;
290
+ if (textToCopy.length === 0) {
291
+ setClipboardStatus('Nothing to copy');
292
+ return;
293
+ }
294
+ void copyTextToClipboard(textToCopy)
295
+ .then(() => {
296
+ setClipboardStatus('Copied input to clipboard');
297
+ })
298
+ .catch((error) => {
299
+ const errorMessage = error instanceof Error ? error.message : 'unknown error';
300
+ setClipboardStatus(`Copy failed: ${errorMessage}`);
301
+ });
302
+ onInputActivity?.();
303
+ }, [inputValue, mode, onInputActivity]);
230
304
  const submitCurrentSelection = useCallback(() => {
231
305
  if (mode === 'option' && predefinedOptions.length > 0) {
232
306
  onSubmit(questionId, predefinedOptions[selectedIndex]);
@@ -250,12 +324,12 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
250
324
  return;
251
325
  }
252
326
  const index = selectedIndexOverride ?? selectedSuggestionIndex;
253
- const selectedSuggestion = availableSuggestions[index] ?? availableSuggestions[0];
327
+ const suggestion = availableSuggestions[index] ?? availableSuggestions[0];
254
328
  const currentValue = safeReadTextarea()?.value ?? inputValue;
255
329
  const nextValue = currentValue.slice(0, target.start) +
256
- selectedSuggestion +
330
+ suggestion +
257
331
  currentValue.slice(target.end);
258
- const nextCaret = target.start + selectedSuggestion.length;
332
+ const nextCaret = target.start + suggestion.length;
259
333
  setTextareaValue(nextValue, nextCaret);
260
334
  }, [
261
335
  fileSuggestions,
@@ -264,19 +338,6 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
264
338
  selectedSuggestionIndex,
265
339
  setTextareaValue,
266
340
  ]);
267
- const hasActiveSearchSuggestions = mode === 'input' &&
268
- activeAutocompleteTarget !== null &&
269
- fileSuggestions.length > 0;
270
- const textareaBindings = useMemo(() => {
271
- if (!hasActiveSearchSuggestions) {
272
- return textareaBaseKeyBindings;
273
- }
274
- return [
275
- ...textareaBaseKeyBindings,
276
- { name: 'enter', action: 'submit' },
277
- { name: 'return', action: 'submit' },
278
- ];
279
- }, [hasActiveSearchSuggestions, textareaBaseKeyBindings]);
280
341
  const insertCharacterInTextarea = useCallback((character) => {
281
342
  if (!character) {
282
343
  return;
@@ -322,8 +383,11 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
322
383
  submitCurrentSelection();
323
384
  return;
324
385
  }
325
- if (predefinedOptions.length > 0 &&
326
- (isReverseTabShortcut(key) || key.name === 'tab')) {
386
+ if (isCopyShortcut(key)) {
387
+ copyInputToClipboard();
388
+ return;
389
+ }
390
+ if (hasOptions && (isReverseTabShortcut(key) || key.name === 'tab')) {
327
391
  if (mode === 'option') {
328
392
  setModeToInput();
329
393
  }
@@ -332,7 +396,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
332
396
  }
333
397
  return;
334
398
  }
335
- if (mode === 'option' && predefinedOptions.length > 0) {
399
+ if (mode === 'option' && hasOptions) {
336
400
  const isOptionSubmitKey = key.name === 'enter' ||
337
401
  key.name === 'return' ||
338
402
  key.sequence === '\r' ||
@@ -349,23 +413,14 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
349
413
  setModeToOption();
350
414
  return;
351
415
  }
352
- if (key.name === 'up') {
353
- setSelectedIndex((prev) => (prev - 1 + predefinedOptions.length) % predefinedOptions.length);
354
- onInputActivity?.();
355
- return;
356
- }
357
- if (key.name.toLowerCase() === 'k') {
358
- setSelectedIndex((prev) => (prev - 1 + predefinedOptions.length) % predefinedOptions.length);
359
- onInputActivity?.();
360
- return;
361
- }
362
- if (key.name === 'down') {
363
- setSelectedIndex((prev) => (prev + 1) % predefinedOptions.length);
416
+ if (key.name === 'up' || key.name.toLowerCase() === 'k') {
417
+ setSelectedIndex((previous) => (previous - 1 + predefinedOptions.length) %
418
+ predefinedOptions.length);
364
419
  onInputActivity?.();
365
420
  return;
366
421
  }
367
- if (key.name.toLowerCase() === 'j') {
368
- setSelectedIndex((prev) => (prev + 1) % predefinedOptions.length);
422
+ if (key.name === 'down' || key.name.toLowerCase() === 'j') {
423
+ setSelectedIndex((previous) => (previous + 1) % predefinedOptions.length);
369
424
  onInputActivity?.();
370
425
  return;
371
426
  }
@@ -384,37 +439,30 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
384
439
  return;
385
440
  }
386
441
  if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'n') {
387
- setSelectedSuggestionIndex((prev) => (prev + 1) % fileSuggestions.length);
442
+ setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
388
443
  onInputActivity?.();
389
444
  return;
390
445
  }
391
446
  if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'p') {
392
- setSelectedSuggestionIndex((prev) => prev <= 0 ? fileSuggestions.length - 1 : prev - 1);
447
+ setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
393
448
  onInputActivity?.();
394
449
  return;
395
450
  }
396
451
  if (key.name === 'down') {
397
- setSelectedSuggestionIndex((prev) => (prev + 1) % fileSuggestions.length);
452
+ setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
398
453
  onInputActivity?.();
399
454
  return;
400
455
  }
401
456
  if (key.name === 'up') {
402
- setSelectedSuggestionIndex((prev) => prev <= 0 ? fileSuggestions.length - 1 : prev - 1);
457
+ setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
403
458
  onInputActivity?.();
404
459
  }
405
460
  });
406
- return (_jsxs(_Fragment, { children: [_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, border: true, borderStyle: "single", borderColor: "cyan", backgroundColor: "#121212", paddingLeft: 1, paddingRight: 1, children: [_jsx("text", { fg: "cyan", children: _jsx("strong", { children: "PROMPT" }) }), _jsx(MarkdownText, { content: question, showCodeCopyControls: true })] }), _jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", children: "Mode" }), _jsxs("box", { flexDirection: "row", alignSelf: "flex-start", border: true, borderStyle: "single", borderColor: "orange", backgroundColor: "#151515", paddingLeft: 0, paddingRight: 0, children: [predefinedOptions.length > 0 && (_jsx("box", { justifyContent: "center", paddingLeft: 0, paddingRight: 0, onClick: setModeToOption, backgroundColor: mode === 'option' ? 'orange' : '#151515', children: _jsx("text", { fg: mode === 'option' ? 'black' : 'gray', children: mode === 'option' ? 'Option' : 'option' }) })), predefinedOptions.length > 0 && _jsx("text", { fg: "#3a3a3a", children: "\u2502" }), _jsx("box", { justifyContent: "center", paddingLeft: 0, paddingRight: 0, onClick: recoverInputFocusFromClick, backgroundColor: mode === 'input' ? 'orange' : '#151515', children: _jsx("text", { fg: mode === 'input' ? 'black' : 'gray', children: mode === 'input' ? 'Input' : 'input' }) })] })] }), predefinedOptions.length > 0 && (_jsxs("box", { flexDirection: "column", marginBottom: 1, width: "100%", gap: 1, children: [_jsx("text", { fg: "gray", wrapMode: "word", children: "Option mode: \u2191/\u2193 or j/k choose \u2022 Enter select \u2022 Tab switch mode" }), _jsx("box", { flexDirection: "column", width: "100%", gap: 1, children: predefinedOptions.map((opt, i) => (_jsx("box", { width: "100%", paddingLeft: 0, paddingRight: 1, onClick: () => {
407
- setSelectedIndex(i);
408
- setModeToOption();
409
- }, children: _jsxs("text", { wrapMode: "char", fg: i === selectedIndex && mode === 'option' ? 'cyan' : 'gray', children: [i === selectedIndex && mode === 'option' ? '› ' : ' ', opt] }) }, `${opt}-${i}`))) })] })), mode === 'input' && (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", children: [_jsx("text", { fg: "gray", children: "Input" }), _jsx("box", { border: true, borderStyle: "single", borderColor: fileSuggestions.length > 0 ? 'cyan' : 'gray', backgroundColor: "#1f1f1f", width: "100%", height: isNarrow ? 4 : 6, paddingLeft: 0, paddingRight: 0, onClick: recoverInputFocusFromClick, children: _jsx("textarea", { ref: textareaRef, focused: true, wrapMode: "word", backgroundColor: "#1f1f1f", focusedBackgroundColor: "#1f1f1f", textColor: "white", focusedTextColor: "white", placeholderColor: "gray", placeholder: "Type your answer...", keyBindings: textareaBindings, onContentChange: syncInputStateFromTextarea, onCursorChange: syncInputStateFromTextarea, onSubmit: handleTextareaSubmit }, `textarea-${questionId}-${textareaRenderVersion}`) })] })), mode === 'input' && activeAutocompleteTarget !== null && (_jsxs("box", { flexDirection: "column", marginBottom: 1, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", children: predefinedOptions.length > 0
410
- ? 'File suggestions • ↑/↓ or Ctrl+N/P navigate • Enter apply'
411
- : 'File suggestions • ↑/↓ or Ctrl+N/P navigate • Enter/Tab apply' }), isIndexingFiles ? (_jsx("text", { fg: "gray", children: "Indexing files..." })) : fileSuggestions.length > 0 ? (_jsx("box", { flexDirection: "column", width: "100%", children: fileSuggestions.map((suggestion, index) => (_jsx("box", { paddingLeft: 0, paddingRight: 1, children: _jsxs("text", { fg: index === selectedSuggestionIndex ? 'cyan' : 'gray', wrapMode: "char", children: [index === selectedSuggestionIndex ? '› ' : ' ', suggestion] }) }, suggestion))) })) : (_jsx("text", { fg: "gray", children: hasSearchRoot
412
- ? '#search: no matches'
413
- : '#search: no search root configured' }))] })), mode === 'input' && (_jsxs("box", { flexDirection: "column", marginBottom: 1, width: "100%", children: [_jsx("text", { fg: "gray", wrapMode: "char", children: hasSearchRoot
461
+ return (_jsxs(_Fragment, { children: [_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, border: true, borderStyle: "single", borderColor: "cyan", backgroundColor: "#121212", paddingLeft: 1, paddingRight: 1, paddingTop: 1, paddingBottom: 1, children: [_jsx("text", { fg: "cyan", children: _jsx("strong", { children: "PROMPT" }) }), _jsx(MarkdownText, { content: question, showCodeCopyControls: true })] }), _jsx(ModeTabs, { mode: mode, hasOptions: hasOptions, onSelectOptionMode: setModeToOption, onSelectInputMode: recoverInputFocusFromClick }), _jsx(OptionList, { mode: mode, options: predefinedOptions, selectedIndex: selectedIndex, onSelectOption: setSelectedIndex, onActivateOptionMode: setModeToOption }), mode === 'input' && (_jsx(InputEditor, { questionId: questionId, textareaRenderVersion: textareaRenderVersion, textareaRef: textareaRef, textareaContainerHeight: textareaContainerHeight, textareaRows: textareaRows, hasSuggestions: fileSuggestions.length > 0, keyBindings: textareaBindings, onFocusRequest: recoverInputFocusFromClick, onContentSync: syncInputStateFromTextarea, onSubmitFromTextarea: handleTextareaSubmit })), mode === 'input' && activeAutocompleteTarget !== null && (_jsx(SuggestionsPanel, { hasOptions: hasOptions, isIndexingFiles: isIndexingFiles, fileSuggestions: fileSuggestions, selectedSuggestionIndex: selectedSuggestionIndex, selectedSuggestionVscodeLink: selectedSuggestionVscodeLink, hasSearchRoot: hasSearchRoot })), mode === 'input' && (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", children: [_jsx("text", { fg: "gray", wrapMode: "char", children: hasSearchRoot
414
462
  ? `#search root: ${searchRoot}`
415
463
  : '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
416
464
  ? '#search index: indexing...'
417
- : `#search index: ${repositoryFiles.length} files indexed` })] })), _jsxs("box", { flexDirection: isNarrow ? 'column' : 'row', justifyContent: "space-between", marginBottom: 1, 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' && (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 1, onClick: submitCurrentSelection, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) })), mode === 'input' && (_jsx("text", { fg: "gray", wrapMode: "word", children: predefinedOptions.length > 0
418
- ? 'Enter/Ctrl+J newline (or #search apply) • #search nav: ↑/↓ or Ctrl+N/P • Tab mode switch • #path for repo file autocomplete'
419
- : 'Enter/Ctrl+J newline • #search nav: ↑/↓ or Ctrl+N/P • Enter/Tab #search apply • #path for repo file autocomplete' }))] }));
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' }))] }));
420
468
  }
@@ -1,10 +1,13 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import * as OpenTuiCore from '@opentui/core';
3
- import { createElement, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
4
4
  import { copyTextToClipboard } from '../utils/clipboard.js';
5
+ import { openExternalLink } from '../utils/open-external-link.js';
5
6
  const { SyntaxStyle, RGBA } = OpenTuiCore;
6
7
  const CODE_BLOCK_REGEX = /```([^\n`]*)\n?([\s\S]*?)```/g;
8
+ const INLINE_LINK_REGEX = /\[([^\]\n]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,!?;:])/g;
7
9
  const DIFF_LANGUAGES = new Set(['diff', 'patch']);
10
+ const VSCODE_FILE_LINK_REGEX = /^vscode(-insiders)?:\/\/file\//;
8
11
  const LANGUAGE_TO_FILETYPE = {
9
12
  js: 'javascript',
10
13
  jsx: 'javascript',
@@ -160,6 +163,52 @@ function splitMarkdownSegments(content) {
160
163
  }
161
164
  return segments;
162
165
  }
166
+ function parseMarkdownInlineLinks(content) {
167
+ const segments = [];
168
+ let lastIndex = 0;
169
+ INLINE_LINK_REGEX.lastIndex = 0;
170
+ for (const match of content.matchAll(INLINE_LINK_REGEX)) {
171
+ const fullMatch = match[0];
172
+ if (typeof fullMatch !== 'string' || typeof match.index !== 'number') {
173
+ continue;
174
+ }
175
+ if (match.index > lastIndex) {
176
+ segments.push({
177
+ type: 'text',
178
+ value: content.slice(lastIndex, match.index),
179
+ });
180
+ }
181
+ const markdownLabel = match[1];
182
+ const markdownHref = match[2];
183
+ const rawHref = match[3];
184
+ const href = markdownHref ?? rawHref;
185
+ const label = markdownLabel ?? rawHref;
186
+ if (href && label) {
187
+ segments.push({
188
+ type: 'link',
189
+ value: label,
190
+ href,
191
+ });
192
+ }
193
+ else {
194
+ segments.push({
195
+ type: 'text',
196
+ value: fullMatch,
197
+ });
198
+ }
199
+ lastIndex = match.index + fullMatch.length;
200
+ }
201
+ if (lastIndex < content.length) {
202
+ segments.push({
203
+ type: 'text',
204
+ value: content.slice(lastIndex),
205
+ });
206
+ }
207
+ return segments.length > 0 ? segments : [{ type: 'text', value: content }];
208
+ }
209
+ function isVscodeFileLink(href) {
210
+ return VSCODE_FILE_LINK_REGEX.test(href);
211
+ }
163
212
  export function MarkdownText({ content, streaming = false, showContentCopyControl = false, contentCopyLabel = 'Copy text', showCodeCopyControls = false, codeBlockMaxVisibleLines, }) {
164
213
  const syntaxStyle = useMemo(() => {
165
214
  const toColor = (hex) => typeof RGBA?.fromHex === 'function' ? RGBA.fromHex(hex) : hex;
@@ -208,12 +257,57 @@ export function MarkdownText({ content, streaming = false, showContentCopyContro
208
257
  setClipboardHint(`Copy failed: ${error instanceof Error ? error.message : String(error)}`);
209
258
  }
210
259
  };
260
+ const openLinkWithHint = useCallback(async (href, target) => {
261
+ try {
262
+ await openExternalLink(href, target);
263
+ setClipboardHint('Opening link…');
264
+ }
265
+ catch (error) {
266
+ setClipboardHint(`Open link failed: ${error instanceof Error ? error.message : String(error)}`);
267
+ }
268
+ }, []);
211
269
  return (_jsxs("box", { flexDirection: "column", width: "100%", gap: 1, children: [showContentCopyControl && (_jsx("box", { width: "100%", justifyContent: "flex-end", children: _jsxs("text", { fg: "cyan", onMouseUp: () => {
212
270
  void copyWithHint(content, 'Prompt copied to clipboard.');
213
271
  }, children: ["[", contentCopyLabel, "]"] }) })), segments.map((segment, index) => {
214
272
  if (segment.type === 'markdown') {
215
273
  if (!segment.value.trim()) {
216
- return _jsx("box", {}, `segment-${index}`);
274
+ const spacerHeight = Math.max(1, segment.value.split('\n').length - 1);
275
+ return _jsx("box", { height: spacerHeight }, `segment-${index}`);
276
+ }
277
+ INLINE_LINK_REGEX.lastIndex = 0;
278
+ if (INLINE_LINK_REGEX.test(segment.value)) {
279
+ INLINE_LINK_REGEX.lastIndex = 0;
280
+ const lines = segment.value.split('\n');
281
+ return (_jsx("box", { flexDirection: "column", width: "100%", children: lines.map((line, lineIndex) => {
282
+ if (!line) {
283
+ return (_jsx("box", { height: 1 }, `segment-${index}-line-${lineIndex}`));
284
+ }
285
+ const inlineSegments = parseMarkdownInlineLinks(line);
286
+ return (_jsx("box", { flexDirection: "row", width: "100%", children: inlineSegments.flatMap((inlineSegment, inlineSegmentIndex) => {
287
+ const baseKey = `segment-${index}-line-${lineIndex}-part-${inlineSegmentIndex}`;
288
+ if (inlineSegment.type !== 'link' ||
289
+ !inlineSegment.href) {
290
+ return (_jsx("text", { wrapMode: "word", children: inlineSegment.value }, baseKey));
291
+ }
292
+ if (!isVscodeFileLink(inlineSegment.href)) {
293
+ return (_jsx("text", { fg: "cyan", wrapMode: "word", onMouseUp: () => {
294
+ void openLinkWithHint(inlineSegment.href ?? '', 'default');
295
+ }, children: inlineSegment.value }, baseKey));
296
+ }
297
+ return [
298
+ _jsx("text", { wrapMode: "word", children: inlineSegment.value }, `${baseKey}-label`),
299
+ _jsx("text", { fg: "gray", wrapMode: "word", children: ' (' }, `${baseKey}-open-paren`),
300
+ _jsx("text", { fg: "cyan", wrapMode: "word", onMouseUp: () => {
301
+ void openLinkWithHint(inlineSegment.href ?? '', 'vscode');
302
+ }, children: "VS Code" }, `${baseKey}-vscode`),
303
+ _jsx("text", { fg: "gray", wrapMode: "word", children: ' | ' }, `${baseKey}-separator`),
304
+ _jsx("text", { fg: "cyan", wrapMode: "word", onMouseUp: () => {
305
+ void openLinkWithHint(inlineSegment.href ?? '', 'vscode-insiders');
306
+ }, children: "VS Code Insiders" }, `${baseKey}-insiders`),
307
+ _jsx("text", { fg: "gray", wrapMode: "word", children: ')' }, `${baseKey}-close-paren`),
308
+ ];
309
+ }) }, `segment-${index}-line-${lineIndex}`));
310
+ }) }, `segment-${index}`));
217
311
  }
218
312
  return (_jsx("markdown", { content: segment.value, syntaxStyle: syntaxStyle, conceal: true, streaming: streaming }, `segment-${index}`));
219
313
  }
@@ -22,6 +22,8 @@ export const isControlKeyShortcut = (key, letter) => {
22
22
  hasCtrlOrMetaModifier(modifierCode));
23
23
  };
24
24
  export const isSubmitShortcut = (key) => isControlKeyShortcut(key, 's');
25
+ export const isCopyShortcut = (key) => isControlKeyShortcut(key, 'c') ||
26
+ (key.ctrl && key.shift && key.name.toLowerCase() === 'c');
25
27
  export const isReverseTabShortcut = (key) => key.name === 'backtab' ||
26
28
  (key.name === 'tab' && key.shift) ||
27
29
  key.sequence === '\u001b[Z';
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { openExternalLink } from '../../utils/open-external-link.js';
3
+ export const ModeTabs = ({ mode, hasOptions, onSelectOptionMode, onSelectInputMode, }) => (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", children: "Mode" }), _jsxs("box", { flexDirection: "row", alignSelf: "flex-start", border: true, borderStyle: "single", borderColor: "orange", backgroundColor: "#151515", paddingLeft: 0, paddingRight: 0, children: [hasOptions && (_jsx("box", { justifyContent: "center", paddingLeft: 0, paddingRight: 0, onClick: onSelectOptionMode, backgroundColor: mode === 'option' ? 'orange' : '#151515', children: _jsx("text", { fg: mode === 'option' ? 'black' : 'gray', children: mode === 'option' ? 'Option' : 'option' }) })), hasOptions && _jsx("text", { fg: "#3a3a3a", children: "\u2502" }), _jsx("box", { justifyContent: "center", paddingLeft: 0, paddingRight: 0, onClick: onSelectInputMode, backgroundColor: mode === 'input' ? 'orange' : '#151515', children: _jsx("text", { fg: mode === 'input' ? 'black' : 'gray', children: mode === 'input' ? 'Input' : 'input' }) })] })] }));
4
+ export const OptionList = ({ mode, options, selectedIndex, onSelectOption, onActivateOptionMode, }) => {
5
+ if (options.length === 0) {
6
+ return null;
7
+ }
8
+ return (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", wrapMode: "word", children: "Option mode: \u2191/\u2193 or j/k choose \u2022 Enter select \u2022 Tab switch mode" }), _jsx("box", { flexDirection: "column", width: "100%", gap: 0, children: options.map((opt, index) => (_jsx("box", { width: "100%", paddingLeft: 0, paddingRight: 1, onClick: () => {
9
+ onSelectOption(index);
10
+ onActivateOptionMode();
11
+ }, children: _jsxs("text", { wrapMode: "char", fg: index === selectedIndex && mode === 'option' ? 'cyan' : 'gray', children: [index === selectedIndex && mode === 'option' ? '› ' : ' ', opt] }) }, `${opt}-${index}`))) })] }));
12
+ };
13
+ export const InputEditor = ({ questionId, textareaRenderVersion, textareaRef, textareaContainerHeight, textareaRows, hasSuggestions, keyBindings, onFocusRequest, onContentSync, onSubmitFromTextarea, }) => (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", children: [_jsx("text", { fg: "gray", children: "Input" }), _jsx("box", { border: true, borderStyle: "single", borderColor: hasSuggestions ? 'cyan' : 'gray', backgroundColor: "#1f1f1f", width: "100%", height: textareaContainerHeight, paddingLeft: 0, paddingRight: 0, onClick: onFocusRequest, children: _jsx("textarea", { ref: textareaRef, focused: true, height: textareaRows, wrapMode: "word", backgroundColor: "#1f1f1f", focusedBackgroundColor: "#1f1f1f", textColor: "white", focusedTextColor: "white", placeholderColor: "gray", placeholder: "Type your answer...", keyBindings: keyBindings, onContentChange: onContentSync, onCursorChange: onContentSync, onSubmit: onSubmitFromTextarea }, `textarea-${questionId}-${textareaRenderVersion}`) })] }));
14
+ export const SuggestionsPanel = ({ hasOptions, isIndexingFiles, fileSuggestions, selectedSuggestionIndex, selectedSuggestionVscodeLink, hasSearchRoot, }) => (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", children: hasOptions
15
+ ? 'File suggestions • ↑/↓ or Ctrl+N/P navigate • Enter apply'
16
+ : 'File suggestions • ↑/↓ or Ctrl+N/P navigate • Enter/Tab apply' }), isIndexingFiles ? (_jsx("text", { fg: "gray", children: "Indexing files..." })) : fileSuggestions.length > 0 ? (_jsxs("box", { flexDirection: "column", width: "100%", children: [fileSuggestions.map((suggestion, index) => (_jsx("box", { paddingLeft: 0, paddingRight: 1, gap: 0, children: _jsxs("text", { fg: index === selectedSuggestionIndex ? 'cyan' : 'gray', wrapMode: "char", children: [index === selectedSuggestionIndex ? '› ' : ' ', suggestion] }) }, suggestion))), selectedSuggestionVscodeLink && (_jsxs("box", { flexDirection: "column", width: "100%", children: [_jsx("text", { fg: "gray", wrapMode: "word", children: "open file with:" }), _jsx("text", { fg: "cyan", wrapMode: "word", onMouseUp: () => {
17
+ void openExternalLink(selectedSuggestionVscodeLink, 'vscode');
18
+ }, children: "\u2022 VS Code" }), _jsx("text", { fg: "cyan", wrapMode: "word", onMouseUp: () => {
19
+ void openExternalLink(selectedSuggestionVscodeLink, 'vscode-insiders');
20
+ }, children: "\u2022 VS Code Insiders" })] }))] })) : (_jsx("text", { fg: "gray", children: hasSearchRoot
21
+ ? '#search: no matches'
22
+ : '#search: no search root configured' }))] }));
@@ -0,0 +1,35 @@
1
+ const NARROW_TERMINAL_MIN_ROWS = 4;
2
+ const WIDE_TERMINAL_MIN_ROWS = 5;
3
+ const NARROW_TERMINAL_MAX_ROWS = 8;
4
+ const WIDE_TERMINAL_MAX_ROWS = 12;
5
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
6
+ const countVisualColumns = (line) => {
7
+ if (line.length === 0) {
8
+ return 0;
9
+ }
10
+ return line.replace(/\t/g, ' ').length;
11
+ };
12
+ const estimateWrappedRows = (value, availableColumns) => {
13
+ const normalizedValue = value.replace(/\r\n/g, '\n');
14
+ const lines = normalizedValue.split('\n');
15
+ return lines.reduce((totalRows, line) => {
16
+ const visualColumns = countVisualColumns(line);
17
+ const lineRows = Math.max(1, Math.ceil(visualColumns / availableColumns));
18
+ return totalRows + lineRows;
19
+ }, 0);
20
+ };
21
+ export const getTextareaDimensions = ({ value, width, terminalHeight, isNarrow, }) => {
22
+ const minRows = isNarrow ? NARROW_TERMINAL_MIN_ROWS : WIDE_TERMINAL_MIN_ROWS;
23
+ const maxRows = isNarrow ? NARROW_TERMINAL_MAX_ROWS : WIDE_TERMINAL_MAX_ROWS;
24
+ const reservedChromeRows = isNarrow ? 24 : 20;
25
+ const maxContainerHeight = Math.max(6, terminalHeight - reservedChromeRows);
26
+ const terminalSafeMaxRows = Math.max(minRows, Math.min(maxRows, maxContainerHeight - 2));
27
+ const estimatedPadding = isNarrow ? 14 : 18;
28
+ const availableColumns = Math.max(14, width - estimatedPadding);
29
+ const estimatedRows = estimateWrappedRows(value, availableColumns);
30
+ const rows = clamp(estimatedRows, minRows, terminalSafeMaxRows);
31
+ return {
32
+ rows,
33
+ containerHeight: rows + 2,
34
+ };
35
+ };
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';
@@ -95,8 +95,7 @@ if (isToolEnabled('request_user_input')) {
95
95
  const { projectName, message, predefinedOptions, baseDirectory } = args;
96
96
  try {
97
97
  const validatedBaseDirectory = await validateRepositoryBaseDirectory(baseDirectory);
98
- const promptMessage = `${projectName}: ${message}`;
99
- const answer = await getCmdWindowInput(projectName, promptMessage, globalTimeoutSeconds, true, validatedBaseDirectory, predefinedOptions);
98
+ const answer = await getCmdWindowInput(projectName, message, globalTimeoutSeconds, true, validatedBaseDirectory, predefinedOptions);
100
99
  // Check for the specific timeout indicator
101
100
  if (answer === USER_INPUT_TIMEOUT_SENTINEL) {
102
101
  return {
@@ -159,10 +158,11 @@ if (isToolEnabled('start_intensive_chat')) {
159
158
  const { sessionTitle, baseDirectory } = args;
160
159
  try {
161
160
  const validatedBaseDirectory = await validateRepositoryBaseDirectory(baseDirectory);
162
- // Start a new intensive chat session, passing global timeout
163
161
  const sessionId = await startIntensiveChatSession(sessionTitle, validatedBaseDirectory, globalTimeoutSeconds);
164
162
  // Track this session for the client
165
- activeChatSessions.set(sessionId, sessionTitle);
163
+ activeChatSessions.set(sessionId, {
164
+ title: sessionTitle,
165
+ });
166
166
  return {
167
167
  content: [
168
168
  {
@@ -201,8 +201,8 @@ if (isToolEnabled('ask_intensive_chat')) {
201
201
  async (args) => {
202
202
  // Use inferred args type
203
203
  const { sessionId, question, predefinedOptions, baseDirectory } = args;
204
- // Check if session exists
205
- if (!activeChatSessions.has(sessionId)) {
204
+ const activeSession = activeChatSessions.get(sessionId);
205
+ if (!activeSession) {
206
206
  return {
207
207
  content: [
208
208
  { type: 'text', text: 'Error: Invalid or expired session ID.' },
@@ -211,7 +211,6 @@ if (isToolEnabled('ask_intensive_chat')) {
211
211
  }
212
212
  try {
213
213
  const validatedBaseDirectory = await validateRepositoryBaseDirectory(baseDirectory);
214
- // Ask the question in the session
215
214
  const answer = await askQuestionInSession(sessionId, question, validatedBaseDirectory, predefinedOptions);
216
215
  // Check for the specific timeout indicator
217
216
  if (answer === USER_INPUT_TIMEOUT_SENTINEL) {
@@ -280,8 +279,8 @@ if (isToolEnabled('stop_intensive_chat')) {
280
279
  async (args) => {
281
280
  // Use inferred args type
282
281
  const { sessionId } = args;
283
- // Check if session exists
284
- if (!activeChatSessions.has(sessionId)) {
282
+ const activeSession = activeChatSessions.get(sessionId);
283
+ if (!activeSession) {
285
284
  return {
286
285
  content: [
287
286
  { type: 'text', text: 'Error: Invalid or expired session ID.' },
@@ -289,7 +288,6 @@ if (isToolEnabled('stop_intensive_chat')) {
289
288
  };
290
289
  }
291
290
  try {
292
- // Stop the session
293
291
  const success = await stopIntensiveChatSession(sessionId);
294
292
  // Remove session from map if successful
295
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.',
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,19 +42,22 @@ 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
47
+ - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") in prompt content
46
48
  - Supports option mode + free-text mode while asking follow-up questions
47
49
  - Configurable timeout for each question (set via -t/--timeout, defaults to ${globalTimeoutSeconds} seconds)
48
50
  - Returns a session ID for subsequent interactions
49
51
  - Keeps full chat history visible to the user
50
52
  - Maintains state between questions
53
+ - Backend-agnostic contract: start/ask/stop behavior is consistent across available UI backends
51
54
  - Requires baseDirectory and pins autocomplete/search scope to the repository root
52
55
  </features>
53
56
 
54
57
  <bestPractices>
55
58
  - Use a descriptive session title related to the task
56
59
  - Start with a clear initial question when possible
60
+ - Use markdown for longer/multiline prompts, code fences, and diff context
57
61
  - Do not ask the question if you have another tool that can answer the question
58
62
  - e.g. when you searching file in the current repository, do not ask the question "Do you want to search for a file in the current repository?"
59
63
  - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools)
@@ -83,7 +87,7 @@ const startToolDefinition = {
83
87
  };
84
88
  // === Ask Intensive Chat Definition ===
85
89
  const askCapability = {
86
- description: 'Ask a question in an active OpenTUI intensive chat session.',
90
+ description: 'Ask a markdown-friendly question in an active intensive chat session.',
87
91
  parameters: {
88
92
  type: 'object',
89
93
  properties: {
@@ -118,6 +122,8 @@ Ask a new question in an active intensive chat session previously started with '
118
122
  - (!important!) Supports predefined options for quick selection.
119
123
  - (!important!) Returns the user's answer or indicates if they didn't respond.
120
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".
121
127
  </importantNotes>
122
128
 
123
129
  <whenToUseThisTool>
@@ -129,6 +135,8 @@ Ask a new question in an active intensive chat session previously started with '
129
135
 
130
136
  <features>
131
137
  - Adds a new question to an existing chat session
138
+ - Supports markdown-friendly prompts (including multiline text, code fences, and diff snippets)
139
+ - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") in question text
132
140
  - Supports predefined options for quick selection
133
141
  - Returns the user's response
134
142
  - Maintains the chat history in the console
@@ -189,9 +197,10 @@ const stopDescription = `<description>
189
197
  </description>
190
198
 
191
199
  <importantNotes>
192
- - (!important!) Closes the console window for the intensive chat.
200
+ - (!important!) Closes the active intensive chat session.
193
201
  - (!important!) Frees up system resources.
194
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".
195
204
  </importantNotes>
196
205
 
197
206
  <whenToUseThisTool>
@@ -203,7 +212,7 @@ const stopDescription = `<description>
203
212
  </whenToUseThisTool>
204
213
 
205
214
  <features>
206
- - Gracefully closes the console window
215
+ - Gracefully closes the active session in the current backend
207
216
  - Cleans up system resources
208
217
  - Marks the session as complete
209
218
  </features>
@@ -1,17 +1,17 @@
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 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: {
8
8
  projectName: {
9
9
  type: 'string',
10
- description: 'Identifies the context/project making the request (used in prompt formatting)',
10
+ description: 'Identifies the context/project making the request (shown in prompt header/title context)',
11
11
  },
12
12
  message: {
13
13
  type: 'string',
14
- description: 'The specific question for the user (appears in the prompt)',
14
+ description: 'The specific question for the user (prompt body text)',
15
15
  },
16
16
  predefinedOptions: {
17
17
  type: 'array',
@@ -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,22 +57,30 @@ 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)
69
+ - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") when provided in the prompt text
59
70
  - Supports option mode + free-text input mode when predefinedOptions are provided
60
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
61
73
  - Maintains context across user interactions
62
74
  - Handles empty responses gracefully
63
- - Properly formats prompt with project context
75
+ - Shows project context in the prompt header/title
64
76
  - baseDirectory is required, must be the current repository root, and controls file autocomplete/search scope explicitly
65
77
  </features>
66
78
 
67
79
  <bestPractices>
68
80
  - Keep questions concise and specific
69
81
  - Provide clear options when applicable
82
+ - Use markdown for richer context (multiline structure, code fences, unified diff snippets)
83
+ - When referencing repository files, prefer VS Code-compatible file links in markdown where helpful
70
84
  - Do not ask the question if you have another tool that can answer the question
71
85
  - e.g. when you searching file in the current repository, do not ask the question "Do you want to search for a file in the current repository?"
72
86
  - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools)
@@ -78,8 +92,8 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
78
92
  </bestPractices>
79
93
 
80
94
  <parameters>
81
- - projectName: Identifies the context/project making the request (used in prompt formatting)
82
- - message: The specific question for the user (appears in the prompt)
95
+ - projectName: Identifies the context/project making the request (shown in prompt header/title context)
96
+ - message: The specific question for the user (prompt body text)
83
97
  - predefinedOptions: Predefined options for the user to choose from (optional)
84
98
  - baseDirectory: Required absolute path to the current repository root (must be a git repo root)
85
99
  </parameters>
@@ -97,10 +111,10 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
97
111
  const rawSchema = {
98
112
  projectName: z
99
113
  .string()
100
- .describe('Identifies the context/project making the request (used in prompt formatting)'),
114
+ .describe('Identifies the context/project making the request (shown in prompt header/title context)'),
101
115
  message: z
102
116
  .string()
103
- .describe('The specific question for the user (appears in the prompt)'),
117
+ .describe('The specific question for the user (prompt body text)'),
104
118
  predefinedOptions: z
105
119
  .array(z.string())
106
120
  .optional()
@@ -0,0 +1,48 @@
1
+ import os from 'node:os';
2
+ import { spawn } from 'node:child_process';
3
+ const normalizeEditorUrl = (url, target) => {
4
+ if (target === 'default') {
5
+ return url;
6
+ }
7
+ if (!/^vscode(-insiders)?:\/\//.test(url)) {
8
+ return url;
9
+ }
10
+ if (target === 'vscode') {
11
+ return url.replace(/^vscode-insiders:\/\//, 'vscode://');
12
+ }
13
+ return url.replace(/^vscode:\/\//, 'vscode-insiders://');
14
+ };
15
+ const getOpenCommand = (url) => {
16
+ const platform = os.platform();
17
+ if (platform === 'darwin') {
18
+ return { command: 'open', args: [url] };
19
+ }
20
+ if (platform === 'linux') {
21
+ return { command: 'xdg-open', args: [url] };
22
+ }
23
+ if (platform === 'win32') {
24
+ return { command: 'cmd', args: ['/c', 'start', '', url] };
25
+ }
26
+ throw new Error(`Opening links is not supported on platform: ${platform}`);
27
+ };
28
+ export async function openExternalLink(url, target = 'default') {
29
+ const trimmedUrl = url.trim();
30
+ if (!trimmedUrl) {
31
+ throw new Error('Cannot open an empty link.');
32
+ }
33
+ const targetUrl = normalizeEditorUrl(trimmedUrl, target);
34
+ const { command, args } = getOpenCommand(targetUrl);
35
+ await new Promise((resolve, reject) => {
36
+ const child = spawn(command, args, {
37
+ stdio: 'ignore',
38
+ detached: true,
39
+ });
40
+ child.once('error', (error) => {
41
+ reject(error);
42
+ });
43
+ child.once('spawn', () => {
44
+ child.unref();
45
+ resolve();
46
+ });
47
+ });
48
+ }
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@rawwee/interactive-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.2.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"