@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 +3 -1
- 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 +1 -2
- package/dist/tool-definitions/intensive-chat.js +6 -2
- package/dist/tool-definitions/request-user-input.js +11 -8
- package/dist/utils/open-external-link.js +48 -0
- package/package.json +1 -1
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:
|
|
@@ -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
|
@@ -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 {
|
|
@@ -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 (
|
|
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',
|
|
@@ -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
|
-
-
|
|
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 (
|
|
82
|
-
- message: The specific question for the user (
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|