@rawwee/interactive-mcp 1.0.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.
@@ -0,0 +1,420 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
+ import * as OpenTuiReact from '@opentui/react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
5
+ import { isPrintableCharacter, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
6
+ import { MarkdownText } from './MarkdownText.js';
7
+ const { useKeyboard } = OpenTuiReact;
8
+ const { useTerminalDimensions } = OpenTuiReact;
9
+ const repositoryFileCache = new Map();
10
+ export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
11
+ const [mode, setMode] = useState(predefinedOptions.length > 0 ? 'option' : 'input');
12
+ const [selectedIndex, setSelectedIndex] = useState(0);
13
+ const [inputValue, setInputValue] = useState('');
14
+ const [caretPosition, setCaretPosition] = useState(0);
15
+ const [repositoryFiles, setRepositoryFiles] = useState([]);
16
+ const [isIndexingFiles, setIsIndexingFiles] = useState(false);
17
+ const [fileSuggestions, setFileSuggestions] = useState([]);
18
+ const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0);
19
+ const [textareaRenderVersion, setTextareaRenderVersion] = useState(0);
20
+ const [focusRequestToken, setFocusRequestToken] = useState(0);
21
+ const textareaRef = useRef(null);
22
+ const latestInputValueRef = useRef(inputValue);
23
+ const latestCaretPositionRef = useRef(caretPosition);
24
+ const autocompleteTargetRef = useRef(null);
25
+ const { width, height } = useTerminalDimensions();
26
+ const isNarrow = width < 90;
27
+ const activeAutocompleteTarget = mode === 'input' ? getAutocompleteTarget(inputValue, caretPosition) : null;
28
+ const hasSearchRoot = Boolean(searchRoot);
29
+ const textareaBaseKeyBindings = useMemo(() => textareaKeyBindings.filter((binding) => binding.action !== 'submit'), []);
30
+ const safeReadTextarea = useCallback(() => {
31
+ const textarea = textareaRef.current;
32
+ if (!textarea) {
33
+ return null;
34
+ }
35
+ try {
36
+ return {
37
+ value: textarea.plainText,
38
+ caret: textarea.cursorOffset,
39
+ };
40
+ }
41
+ catch {
42
+ textareaRef.current = null;
43
+ return null;
44
+ }
45
+ }, []);
46
+ const safeWriteTextarea = useCallback((nextValue, nextCaretPosition) => {
47
+ const textarea = textareaRef.current;
48
+ if (!textarea) {
49
+ return false;
50
+ }
51
+ try {
52
+ if (textarea.plainText !== nextValue) {
53
+ textarea.setText(nextValue);
54
+ }
55
+ textarea.cursorOffset = nextCaretPosition;
56
+ return true;
57
+ }
58
+ catch {
59
+ textareaRef.current = null;
60
+ return false;
61
+ }
62
+ }, []);
63
+ const focusTextarea = useCallback(() => {
64
+ const textarea = textareaRef.current;
65
+ if (!textarea) {
66
+ return false;
67
+ }
68
+ try {
69
+ textarea.focus?.();
70
+ return true;
71
+ }
72
+ catch {
73
+ textareaRef.current = null;
74
+ return false;
75
+ }
76
+ }, []);
77
+ useEffect(() => {
78
+ latestInputValueRef.current = inputValue;
79
+ }, [inputValue]);
80
+ useEffect(() => {
81
+ latestCaretPositionRef.current = caretPosition;
82
+ }, [caretPosition]);
83
+ useEffect(() => {
84
+ let active = true;
85
+ const repositoryRoot = searchRoot;
86
+ autocompleteTargetRef.current = null;
87
+ setFileSuggestions([]);
88
+ setSelectedSuggestionIndex(0);
89
+ if (!repositoryRoot) {
90
+ setRepositoryFiles([]);
91
+ setIsIndexingFiles(false);
92
+ return () => {
93
+ active = false;
94
+ };
95
+ }
96
+ const cachedFiles = repositoryFileCache.get(repositoryRoot);
97
+ if (cachedFiles) {
98
+ setRepositoryFiles(cachedFiles);
99
+ setIsIndexingFiles(false);
100
+ return () => {
101
+ active = false;
102
+ };
103
+ }
104
+ setRepositoryFiles([]);
105
+ setIsIndexingFiles(true);
106
+ void readRepositoryFiles(repositoryRoot)
107
+ .then((files) => {
108
+ if (!active) {
109
+ return;
110
+ }
111
+ repositoryFileCache.set(repositoryRoot, files);
112
+ setRepositoryFiles(files);
113
+ setIsIndexingFiles(false);
114
+ })
115
+ .catch(() => {
116
+ if (!active) {
117
+ return;
118
+ }
119
+ setRepositoryFiles([]);
120
+ setIsIndexingFiles(false);
121
+ });
122
+ return () => {
123
+ active = false;
124
+ };
125
+ }, [searchRoot]);
126
+ useEffect(() => {
127
+ setMode(predefinedOptions.length > 0 ? 'option' : 'input');
128
+ setSelectedIndex(0);
129
+ setInputValue('');
130
+ setCaretPosition(0);
131
+ latestInputValueRef.current = '';
132
+ latestCaretPositionRef.current = 0;
133
+ setFileSuggestions([]);
134
+ setSelectedSuggestionIndex(0);
135
+ safeWriteTextarea('', 0);
136
+ }, [predefinedOptions.length, questionId, safeWriteTextarea]);
137
+ useEffect(() => {
138
+ if (mode !== 'input') {
139
+ return;
140
+ }
141
+ const nextValue = latestInputValueRef.current;
142
+ const clampedCaret = Math.max(0, Math.min(latestCaretPositionRef.current, nextValue.length));
143
+ const didWrite = safeWriteTextarea(nextValue, clampedCaret);
144
+ if (!didWrite) {
145
+ setTextareaRenderVersion((prev) => prev + 1);
146
+ return;
147
+ }
148
+ if (!focusTextarea()) {
149
+ setTextareaRenderVersion((prev) => prev + 1);
150
+ }
151
+ }, [
152
+ focusRequestToken,
153
+ focusTextarea,
154
+ height,
155
+ mode,
156
+ questionId,
157
+ safeWriteTextarea,
158
+ textareaRenderVersion,
159
+ width,
160
+ ]);
161
+ useEffect(() => {
162
+ if (mode !== 'input' || repositoryFiles.length === 0) {
163
+ autocompleteTargetRef.current = null;
164
+ setFileSuggestions([]);
165
+ setSelectedSuggestionIndex(0);
166
+ return;
167
+ }
168
+ const target = getAutocompleteTarget(inputValue, caretPosition);
169
+ autocompleteTargetRef.current = target;
170
+ if (!target) {
171
+ setFileSuggestions([]);
172
+ setSelectedSuggestionIndex(0);
173
+ return;
174
+ }
175
+ const nextSuggestions = rankFileSuggestions(repositoryFiles, target.query);
176
+ setFileSuggestions(nextSuggestions);
177
+ setSelectedSuggestionIndex((prev) => nextSuggestions.length === 0
178
+ ? 0
179
+ : Math.min(prev, nextSuggestions.length - 1));
180
+ }, [caretPosition, inputValue, mode, repositoryFiles]);
181
+ const syncInputStateFromTextarea = useCallback(() => {
182
+ const textareaState = safeReadTextarea();
183
+ if (!textareaState) {
184
+ return;
185
+ }
186
+ const nextValue = textareaState.value;
187
+ const nextCaret = textareaState.caret;
188
+ const didChange = nextValue !== latestInputValueRef.current ||
189
+ nextCaret !== latestCaretPositionRef.current;
190
+ if (!didChange) {
191
+ return;
192
+ }
193
+ latestInputValueRef.current = nextValue;
194
+ latestCaretPositionRef.current = nextCaret;
195
+ setInputValue(nextValue);
196
+ setCaretPosition(nextCaret);
197
+ onInputActivity?.();
198
+ }, [onInputActivity, safeReadTextarea]);
199
+ const setTextareaValue = useCallback((nextValue, nextCaretPosition) => {
200
+ const clampedCaret = Math.max(0, Math.min(nextCaretPosition, nextValue.length));
201
+ safeWriteTextarea(nextValue, clampedCaret);
202
+ latestInputValueRef.current = nextValue;
203
+ latestCaretPositionRef.current = clampedCaret;
204
+ setInputValue(nextValue);
205
+ setCaretPosition(clampedCaret);
206
+ onInputActivity?.();
207
+ }, [onInputActivity, safeWriteTextarea]);
208
+ const setModeToInput = useCallback(() => {
209
+ setMode('input');
210
+ onInputActivity?.();
211
+ }, [onInputActivity]);
212
+ const requestInputFocus = useCallback((forceRemount = false) => {
213
+ setMode('input');
214
+ if (forceRemount) {
215
+ setTextareaRenderVersion((prev) => prev + 1);
216
+ }
217
+ setFocusRequestToken((prev) => prev + 1);
218
+ onInputActivity?.();
219
+ }, [onInputActivity]);
220
+ const recoverInputFocusFromClick = useCallback(() => {
221
+ requestInputFocus();
222
+ }, [requestInputFocus]);
223
+ const setModeToOption = useCallback(() => {
224
+ if (predefinedOptions.length === 0) {
225
+ return;
226
+ }
227
+ setMode('option');
228
+ onInputActivity?.();
229
+ }, [onInputActivity, predefinedOptions.length]);
230
+ const submitCurrentSelection = useCallback(() => {
231
+ if (mode === 'option' && predefinedOptions.length > 0) {
232
+ onSubmit(questionId, predefinedOptions[selectedIndex]);
233
+ return;
234
+ }
235
+ const textareaValue = safeReadTextarea()?.value ?? inputValue;
236
+ onSubmit(questionId, textareaValue);
237
+ }, [
238
+ inputValue,
239
+ mode,
240
+ onSubmit,
241
+ predefinedOptions,
242
+ questionId,
243
+ safeReadTextarea,
244
+ selectedIndex,
245
+ ]);
246
+ const applySelectedSuggestion = useCallback((targetOverride, suggestionsOverride, selectedIndexOverride) => {
247
+ const target = targetOverride ?? autocompleteTargetRef.current;
248
+ const availableSuggestions = suggestionsOverride ?? fileSuggestions;
249
+ if (!target || availableSuggestions.length === 0) {
250
+ return;
251
+ }
252
+ const index = selectedIndexOverride ?? selectedSuggestionIndex;
253
+ const selectedSuggestion = availableSuggestions[index] ?? availableSuggestions[0];
254
+ const currentValue = safeReadTextarea()?.value ?? inputValue;
255
+ const nextValue = currentValue.slice(0, target.start) +
256
+ selectedSuggestion +
257
+ currentValue.slice(target.end);
258
+ const nextCaret = target.start + selectedSuggestion.length;
259
+ setTextareaValue(nextValue, nextCaret);
260
+ }, [
261
+ fileSuggestions,
262
+ inputValue,
263
+ safeReadTextarea,
264
+ selectedSuggestionIndex,
265
+ setTextareaValue,
266
+ ]);
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
+ const insertCharacterInTextarea = useCallback((character) => {
281
+ if (!character) {
282
+ return;
283
+ }
284
+ const currentValue = safeReadTextarea()?.value ?? inputValue;
285
+ const currentCaret = Math.max(0, Math.min(caretPosition, currentValue.length));
286
+ const nextValue = currentValue.slice(0, currentCaret) +
287
+ character +
288
+ currentValue.slice(currentCaret);
289
+ setTextareaValue(nextValue, currentCaret + character.length);
290
+ }, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
291
+ const handleTextareaSubmit = useCallback(() => {
292
+ const textareaState = safeReadTextarea();
293
+ const currentValue = textareaState?.value ?? inputValue;
294
+ const currentCaret = textareaState?.caret ?? caretPosition;
295
+ const currentTarget = mode === 'input'
296
+ ? getAutocompleteTarget(currentValue, currentCaret)
297
+ : null;
298
+ if (currentTarget && repositoryFiles.length > 0) {
299
+ const nextSuggestions = rankFileSuggestions(repositoryFiles, currentTarget.query);
300
+ if (nextSuggestions.length > 0) {
301
+ autocompleteTargetRef.current = currentTarget;
302
+ const clampedSelectedSuggestionIndex = Math.min(selectedSuggestionIndex, nextSuggestions.length - 1);
303
+ setFileSuggestions(nextSuggestions);
304
+ setSelectedSuggestionIndex(clampedSelectedSuggestionIndex);
305
+ applySelectedSuggestion(currentTarget, nextSuggestions, clampedSelectedSuggestionIndex);
306
+ return;
307
+ }
308
+ }
309
+ insertCharacterInTextarea('\n');
310
+ }, [
311
+ applySelectedSuggestion,
312
+ caretPosition,
313
+ inputValue,
314
+ insertCharacterInTextarea,
315
+ mode,
316
+ repositoryFiles,
317
+ safeReadTextarea,
318
+ selectedSuggestionIndex,
319
+ ]);
320
+ useKeyboard((key) => {
321
+ if (isSubmitShortcut(key)) {
322
+ submitCurrentSelection();
323
+ return;
324
+ }
325
+ if (predefinedOptions.length > 0 &&
326
+ (isReverseTabShortcut(key) || key.name === 'tab')) {
327
+ if (mode === 'option') {
328
+ setModeToInput();
329
+ }
330
+ else {
331
+ setModeToOption();
332
+ }
333
+ return;
334
+ }
335
+ if (mode === 'option' && predefinedOptions.length > 0) {
336
+ const isOptionSubmitKey = key.name === 'enter' ||
337
+ key.name === 'return' ||
338
+ key.sequence === '\r' ||
339
+ key.sequence === '\n';
340
+ if (isOptionSubmitKey) {
341
+ submitCurrentSelection();
342
+ return;
343
+ }
344
+ if (key.name === 'right') {
345
+ setModeToInput();
346
+ return;
347
+ }
348
+ if (key.name === 'left') {
349
+ setModeToOption();
350
+ return;
351
+ }
352
+ if (key.name === 'up') {
353
+ setSelectedIndex((prev) => (prev - 1 + predefinedOptions.length) % predefinedOptions.length);
354
+ onInputActivity?.();
355
+ return;
356
+ }
357
+ if (key.name.toLowerCase() === 'k') {
358
+ setSelectedIndex((prev) => (prev - 1 + predefinedOptions.length) % predefinedOptions.length);
359
+ onInputActivity?.();
360
+ return;
361
+ }
362
+ if (key.name === 'down') {
363
+ setSelectedIndex((prev) => (prev + 1) % predefinedOptions.length);
364
+ onInputActivity?.();
365
+ return;
366
+ }
367
+ if (key.name.toLowerCase() === 'j') {
368
+ setSelectedIndex((prev) => (prev + 1) % predefinedOptions.length);
369
+ onInputActivity?.();
370
+ return;
371
+ }
372
+ const typedCharacter = isPrintableCharacter(key);
373
+ if (typedCharacter !== null) {
374
+ setModeToInput();
375
+ insertCharacterInTextarea(typedCharacter);
376
+ }
377
+ return;
378
+ }
379
+ if (mode !== 'input' || fileSuggestions.length === 0) {
380
+ return;
381
+ }
382
+ if (key.name === 'tab') {
383
+ applySelectedSuggestion();
384
+ return;
385
+ }
386
+ if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'n') {
387
+ setSelectedSuggestionIndex((prev) => (prev + 1) % fileSuggestions.length);
388
+ onInputActivity?.();
389
+ return;
390
+ }
391
+ if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'p') {
392
+ setSelectedSuggestionIndex((prev) => prev <= 0 ? fileSuggestions.length - 1 : prev - 1);
393
+ onInputActivity?.();
394
+ return;
395
+ }
396
+ if (key.name === 'down') {
397
+ setSelectedSuggestionIndex((prev) => (prev + 1) % fileSuggestions.length);
398
+ onInputActivity?.();
399
+ return;
400
+ }
401
+ if (key.name === 'up') {
402
+ setSelectedSuggestionIndex((prev) => prev <= 0 ? fileSuggestions.length - 1 : prev - 1);
403
+ onInputActivity?.();
404
+ }
405
+ });
406
+ return (_jsxs(_Fragment, { children: [_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, border: true, borderStyle: "single", borderColor: "cyan", backgroundColor: "#121212", paddingLeft: 1, paddingRight: 1, children: [_jsx("text", { fg: "cyan", children: _jsx("strong", { children: "PROMPT" }) }), _jsx(MarkdownText, { content: question, showCodeCopyControls: true })] }), _jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", children: "Mode" }), _jsxs("box", { flexDirection: "row", alignSelf: "flex-start", border: true, borderStyle: "single", borderColor: "orange", backgroundColor: "#151515", paddingLeft: 0, paddingRight: 0, children: [predefinedOptions.length > 0 && (_jsx("box", { justifyContent: "center", paddingLeft: 0, paddingRight: 0, onClick: setModeToOption, backgroundColor: mode === 'option' ? 'orange' : '#151515', children: _jsx("text", { fg: mode === 'option' ? 'black' : 'gray', children: mode === 'option' ? 'Option' : 'option' }) })), predefinedOptions.length > 0 && _jsx("text", { fg: "#3a3a3a", children: "\u2502" }), _jsx("box", { justifyContent: "center", paddingLeft: 0, paddingRight: 0, onClick: recoverInputFocusFromClick, backgroundColor: mode === 'input' ? 'orange' : '#151515', children: _jsx("text", { fg: mode === 'input' ? 'black' : 'gray', children: mode === 'input' ? 'Input' : 'input' }) })] })] }), predefinedOptions.length > 0 && (_jsxs("box", { flexDirection: "column", marginBottom: 1, width: "100%", gap: 1, children: [_jsx("text", { fg: "gray", wrapMode: "word", children: "Option mode: \u2191/\u2193 or j/k choose \u2022 Enter select \u2022 Tab switch mode" }), _jsx("box", { flexDirection: "column", width: "100%", gap: 1, children: predefinedOptions.map((opt, i) => (_jsx("box", { width: "100%", paddingLeft: 0, paddingRight: 1, onClick: () => {
407
+ setSelectedIndex(i);
408
+ setModeToOption();
409
+ }, children: _jsxs("text", { wrapMode: "char", fg: i === selectedIndex && mode === 'option' ? 'cyan' : 'gray', children: [i === selectedIndex && mode === 'option' ? '› ' : ' ', opt] }) }, `${opt}-${i}`))) })] })), mode === 'input' && (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", children: [_jsx("text", { fg: "gray", children: "Input" }), _jsx("box", { border: true, borderStyle: "single", borderColor: fileSuggestions.length > 0 ? 'cyan' : 'gray', backgroundColor: "#1f1f1f", width: "100%", height: isNarrow ? 4 : 6, paddingLeft: 0, paddingRight: 0, onClick: recoverInputFocusFromClick, children: _jsx("textarea", { ref: textareaRef, focused: true, wrapMode: "word", backgroundColor: "#1f1f1f", focusedBackgroundColor: "#1f1f1f", textColor: "white", focusedTextColor: "white", placeholderColor: "gray", placeholder: "Type your answer...", keyBindings: textareaBindings, onContentChange: syncInputStateFromTextarea, onCursorChange: syncInputStateFromTextarea, onSubmit: handleTextareaSubmit }, `textarea-${questionId}-${textareaRenderVersion}`) })] })), mode === 'input' && activeAutocompleteTarget !== null && (_jsxs("box", { flexDirection: "column", marginBottom: 1, width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", children: predefinedOptions.length > 0
410
+ ? 'File suggestions • ↑/↓ or Ctrl+N/P navigate • Enter apply'
411
+ : 'File suggestions • ↑/↓ or Ctrl+N/P navigate • Enter/Tab apply' }), isIndexingFiles ? (_jsx("text", { fg: "gray", children: "Indexing files..." })) : fileSuggestions.length > 0 ? (_jsx("box", { flexDirection: "column", width: "100%", children: fileSuggestions.map((suggestion, index) => (_jsx("box", { paddingLeft: 0, paddingRight: 1, children: _jsxs("text", { fg: index === selectedSuggestionIndex ? 'cyan' : 'gray', wrapMode: "char", children: [index === selectedSuggestionIndex ? '› ' : ' ', suggestion] }) }, suggestion))) })) : (_jsx("text", { fg: "gray", children: hasSearchRoot
412
+ ? '#search: no matches'
413
+ : '#search: no search root configured' }))] })), mode === 'input' && (_jsxs("box", { flexDirection: "column", marginBottom: 1, width: "100%", children: [_jsx("text", { fg: "gray", wrapMode: "char", children: hasSearchRoot
414
+ ? `#search root: ${searchRoot}`
415
+ : '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
416
+ ? '#search index: indexing...'
417
+ : `#search index: ${repositoryFiles.length} files indexed` })] })), _jsxs("box", { flexDirection: isNarrow ? 'column' : 'row', justifyContent: "space-between", marginBottom: 1, gap: isNarrow ? 0 : undefined, children: [_jsx("text", { fg: "gray", children: mode === 'input' ? 'Custom input' : 'Option selection' }), _jsxs("text", { fg: "gray", children: [inputValue.length, " chars"] })] }), mode === 'input' && (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 1, onClick: submitCurrentSelection, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) })), mode === 'input' && (_jsx("text", { fg: "gray", wrapMode: "word", children: predefinedOptions.length > 0
418
+ ? 'Enter/Ctrl+J newline (or #search apply) • #search nav: ↑/↓ or Ctrl+N/P • Tab mode switch • #path for repo file autocomplete'
419
+ : 'Enter/Ctrl+J newline • #search nav: ↑/↓ or Ctrl+N/P • Enter/Tab #search apply • #path for repo file autocomplete' }))] }));
420
+ }