@rawwee/interactive-mcp 1.0.0 → 1.1.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:
@@ -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
@@ -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 {
@@ -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 OpenTUI intensive chat session for gathering multiple answers quickly with markdown-friendly prompts.',
5
5
  parameters: {
6
6
  type: 'object',
7
7
  properties: {
@@ -43,6 +43,7 @@ Especially useful for brainstorming ideas or discussing complex topics with the
43
43
  <features>
44
44
  - Opens a persistent OpenTUI window for continuous interaction
45
45
  - Renders markdown prompts, including code/diff snippets, for richer question context
46
+ - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") in prompt content
46
47
  - Supports option mode + free-text mode while asking follow-up questions
47
48
  - Configurable timeout for each question (set via -t/--timeout, defaults to ${globalTimeoutSeconds} seconds)
48
49
  - Returns a session ID for subsequent interactions
@@ -54,6 +55,7 @@ Especially useful for brainstorming ideas or discussing complex topics with the
54
55
  <bestPractices>
55
56
  - Use a descriptive session title related to the task
56
57
  - Start with a clear initial question when possible
58
+ - Use markdown for longer/multiline prompts, code fences, and diff context
57
59
  - Do not ask the question if you have another tool that can answer the question
58
60
  - 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
61
  - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools)
@@ -83,7 +85,7 @@ const startToolDefinition = {
83
85
  };
84
86
  // === Ask Intensive Chat Definition ===
85
87
  const askCapability = {
86
- description: 'Ask a question in an active OpenTUI intensive chat session.',
88
+ description: 'Ask a markdown-friendly question in an active OpenTUI intensive chat session.',
87
89
  parameters: {
88
90
  type: 'object',
89
91
  properties: {
@@ -129,6 +131,8 @@ Ask a new question in an active intensive chat session previously started with '
129
131
 
130
132
  <features>
131
133
  - Adds a new question to an existing chat session
134
+ - Supports markdown-friendly prompts (including multiline text, code fences, and diff snippets)
135
+ - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") in question text
132
136
  - Supports predefined options for quick selection
133
137
  - Returns the user's response
134
138
  - Maintains the chat history in the console
@@ -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 OpenTUI terminal prompt with markdown-friendly rendering 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',
@@ -56,17 +56,20 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
56
56
 
57
57
  <features>
58
58
  - OpenTUI prompt with markdown rendering (including code/diff blocks)
59
+ - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") when provided in the prompt text
59
60
  - Supports option mode + free-text input mode when predefinedOptions are provided
60
61
  - Returns user response or timeout notification (timeout defaults to ${globalTimeoutSeconds} seconds)
61
62
  - Maintains context across user interactions
62
63
  - Handles empty responses gracefully
63
- - Properly formats prompt with project context
64
+ - Shows project context in the prompt header/title
64
65
  - baseDirectory is required, must be the current repository root, and controls file autocomplete/search scope explicitly
65
66
  </features>
66
67
 
67
68
  <bestPractices>
68
69
  - Keep questions concise and specific
69
70
  - Provide clear options when applicable
71
+ - Use markdown for richer context (multiline structure, code fences, unified diff snippets)
72
+ - When referencing repository files, prefer VS Code-compatible file links in markdown where helpful
70
73
  - Do not ask the question if you have another tool that can answer the question
71
74
  - 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
75
  - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools)
@@ -78,8 +81,8 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
78
81
  </bestPractices>
79
82
 
80
83
  <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)
84
+ - projectName: Identifies the context/project making the request (shown in prompt header/title context)
85
+ - message: The specific question for the user (prompt body text)
83
86
  - predefinedOptions: Predefined options for the user to choose from (optional)
84
87
  - baseDirectory: Required absolute path to the current repository root (must be a git repo root)
85
88
  </parameters>
@@ -97,10 +100,10 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
97
100
  const rawSchema = {
98
101
  projectName: z
99
102
  .string()
100
- .describe('Identifies the context/project making the request (used in prompt formatting)'),
103
+ .describe('Identifies the context/project making the request (shown in prompt header/title context)'),
101
104
  message: z
102
105
  .string()
103
- .describe('The specific question for the user (appears in the prompt)'),
106
+ .describe('The specific question for the user (prompt body text)'),
104
107
  predefinedOptions: z
105
108
  .array(z.string())
106
109
  .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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rawwee/interactive-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {