@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 +7 -6
- package/dist/commands/input/ui.js +2 -4
- package/dist/components/InteractiveInput.js +104 -56
- package/dist/components/MarkdownText.js +96 -2
- package/dist/components/interactive-input/keyboard.js +2 -0
- package/dist/components/interactive-input/sections.js +22 -0
- package/dist/components/interactive-input/textarea-height.js +35 -0
- package/dist/index.js +9 -11
- package/dist/tool-definitions/intensive-chat.js +16 -7
- package/dist/tool-definitions/request-user-input.js +24 -10
- package/dist/utils/open-external-link.js +48 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@rawwee/interactive-mcp) [](https://www.npmjs.com/package/@rawwee/interactive-mcp) [](https://github.com/josippapez/interactive-mcp-server/blob/main/LICENSE) [](https://github.com/prettier/prettier) [](https://github.com/josippapez/interactive-mcp-server) [](https://github.com/josippapez/interactive-mcp-server/commits/main)
|
|
4
4
|
|
|
5
|
-

|
|
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
|
-
###
|
|
133
|
-
|
|
134
|
-
`interactive-mcp` now uses OpenTUI (`@opentui/core`, `@opentui/react`) for terminal UI rendering.
|
|
134
|
+
### UI backend status
|
|
135
135
|
|
|
136
|
-
|
|
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.
|
|
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 {
|
|
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:
|
|
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
|
|
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((
|
|
193
|
+
setTextareaRenderVersion((previous) => previous + 1);
|
|
146
194
|
return;
|
|
147
195
|
}
|
|
148
196
|
if (!focusTextarea()) {
|
|
149
|
-
setTextareaRenderVersion((
|
|
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((
|
|
225
|
+
setSelectedSuggestionIndex((previous) => nextSuggestions.length === 0
|
|
178
226
|
? 0
|
|
179
|
-
: Math.min(
|
|
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((
|
|
263
|
+
setTextareaRenderVersion((previous) => previous + 1);
|
|
216
264
|
}
|
|
217
|
-
setFocusRequestToken((
|
|
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
|
|
327
|
+
const suggestion = availableSuggestions[index] ?? availableSuggestions[0];
|
|
254
328
|
const currentValue = safeReadTextarea()?.value ?? inputValue;
|
|
255
329
|
const nextValue = currentValue.slice(0, target.start) +
|
|
256
|
-
|
|
330
|
+
suggestion +
|
|
257
331
|
currentValue.slice(target.end);
|
|
258
|
-
const nextCaret = target.start +
|
|
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 (
|
|
326
|
-
(
|
|
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' &&
|
|
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((
|
|
354
|
-
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
452
|
+
setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
|
|
398
453
|
onInputActivity?.();
|
|
399
454
|
return;
|
|
400
455
|
}
|
|
401
456
|
if (key.name === 'up') {
|
|
402
|
-
setSelectedSuggestionIndex((
|
|
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 })] }),
|
|
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:
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
205
|
-
if (!
|
|
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
|
-
|
|
284
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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 (
|
|
82
|
-
- message: The specific question for the user (
|
|
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 (
|
|
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 (
|
|
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.
|
|
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"
|