@rawwee/interactive-mcp 1.4.0 → 1.4.1
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/dist/components/InteractiveInput.js +71 -190
- package/dist/components/interactive-input/keyboard-router.js +104 -0
- package/dist/components/interactive-input/sections.js +15 -0
- package/dist/components/interactive-input/submit-handler.js +21 -0
- package/dist/components/interactive-input/textarea-operations.js +62 -0
- package/package.json +1 -1
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
2
|
import * as OpenTuiReact from '@opentui/react';
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
|
|
6
|
-
import {
|
|
7
|
-
import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, } from './interactive-input/sections.js';
|
|
6
|
+
import { textareaKeyBindings } from './interactive-input/keyboard.js';
|
|
7
|
+
import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, QuestionBox, SearchStatus, InputStatus, ClipboardStatus, AttachmentsDisplay, SendButton, HelpText, } from './interactive-input/sections.js';
|
|
8
8
|
import { getTextareaDimensions } from './interactive-input/textarea-height.js';
|
|
9
9
|
import { MarkdownText } from './MarkdownText.js';
|
|
10
10
|
import { repositoryFileCache, } from './interactive-input/constants.js';
|
|
11
11
|
import { createClipboardHandlers } from './interactive-input/clipboard-handlers.js';
|
|
12
|
+
import { safeReadTextarea, safeWriteTextarea, focusTextarea, } from './interactive-input/textarea-operations.js';
|
|
13
|
+
import { createSubmitHandler } from './interactive-input/submit-handler.js';
|
|
14
|
+
import { createKeyboardRouter } from './interactive-input/keyboard-router.js';
|
|
12
15
|
const { useKeyboard } = OpenTuiReact;
|
|
13
16
|
const { useTerminalDimensions } = OpenTuiReact;
|
|
14
17
|
export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
|
|
@@ -65,53 +68,6 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
65
68
|
: `/${normalizedPath}`;
|
|
66
69
|
return `vscode://file${encodeURI(vscodePath)}`;
|
|
67
70
|
}, [searchRoot, selectedSuggestion]);
|
|
68
|
-
const safeReadTextarea = useCallback(() => {
|
|
69
|
-
const textarea = textareaRef.current;
|
|
70
|
-
if (!textarea) {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
return {
|
|
75
|
-
value: textarea.plainText,
|
|
76
|
-
caret: textarea.cursorOffset,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
textareaRef.current = null;
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
}, []);
|
|
84
|
-
const safeWriteTextarea = useCallback((nextValue, nextCaretPosition) => {
|
|
85
|
-
const textarea = textareaRef.current;
|
|
86
|
-
if (!textarea) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
try {
|
|
90
|
-
if (textarea.plainText !== nextValue) {
|
|
91
|
-
textarea.setText(nextValue);
|
|
92
|
-
}
|
|
93
|
-
textarea.cursorOffset = nextCaretPosition;
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
textareaRef.current = null;
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}, []);
|
|
101
|
-
const focusTextarea = useCallback(() => {
|
|
102
|
-
const textarea = textareaRef.current;
|
|
103
|
-
if (!textarea) {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
try {
|
|
107
|
-
textarea.focus?.();
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
textareaRef.current = null;
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
}, []);
|
|
115
71
|
useEffect(() => {
|
|
116
72
|
latestInputValueRef.current = inputValue;
|
|
117
73
|
}, [inputValue]);
|
|
@@ -206,29 +162,27 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
206
162
|
latestCaretPositionRef.current = 0;
|
|
207
163
|
setFileSuggestions([]);
|
|
208
164
|
setSelectedSuggestionIndex(0);
|
|
209
|
-
safeWriteTextarea('', 0);
|
|
210
|
-
}, [predefinedOptions.length, questionId
|
|
165
|
+
safeWriteTextarea(textareaRef, '', 0);
|
|
166
|
+
}, [predefinedOptions.length, questionId]);
|
|
211
167
|
useEffect(() => {
|
|
212
168
|
if (mode !== 'input') {
|
|
213
169
|
return;
|
|
214
170
|
}
|
|
215
171
|
const nextValue = latestInputValueRef.current;
|
|
216
172
|
const clampedCaret = Math.max(0, Math.min(latestCaretPositionRef.current, nextValue.length));
|
|
217
|
-
const didWrite = safeWriteTextarea(nextValue, clampedCaret);
|
|
173
|
+
const didWrite = safeWriteTextarea(textareaRef, nextValue, clampedCaret);
|
|
218
174
|
if (!didWrite) {
|
|
219
175
|
setTextareaRenderVersion((previous) => previous + 1);
|
|
220
176
|
return;
|
|
221
177
|
}
|
|
222
|
-
if (!focusTextarea()) {
|
|
178
|
+
if (!focusTextarea(textareaRef)) {
|
|
223
179
|
setTextareaRenderVersion((previous) => previous + 1);
|
|
224
180
|
}
|
|
225
181
|
}, [
|
|
226
182
|
focusRequestToken,
|
|
227
|
-
focusTextarea,
|
|
228
183
|
height,
|
|
229
184
|
mode,
|
|
230
185
|
questionId,
|
|
231
|
-
safeWriteTextarea,
|
|
232
186
|
textareaRenderVersion,
|
|
233
187
|
width,
|
|
234
188
|
]);
|
|
@@ -253,7 +207,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
253
207
|
: Math.min(previous, nextSuggestions.length - 1));
|
|
254
208
|
}, [caretPosition, inputValue, mode, repositoryFiles]);
|
|
255
209
|
const syncInputStateFromTextarea = useCallback(() => {
|
|
256
|
-
const textareaState = safeReadTextarea();
|
|
210
|
+
const textareaState = safeReadTextarea(textareaRef);
|
|
257
211
|
if (!textareaState) {
|
|
258
212
|
return;
|
|
259
213
|
}
|
|
@@ -269,16 +223,16 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
269
223
|
setInputValue(nextValue);
|
|
270
224
|
setCaretPosition(nextCaret);
|
|
271
225
|
onInputActivity?.();
|
|
272
|
-
}, [onInputActivity
|
|
226
|
+
}, [onInputActivity]);
|
|
273
227
|
const setTextareaValue = useCallback((nextValue, nextCaretPosition) => {
|
|
274
228
|
const clampedCaret = Math.max(0, Math.min(nextCaretPosition, nextValue.length));
|
|
275
|
-
safeWriteTextarea(nextValue, clampedCaret);
|
|
229
|
+
safeWriteTextarea(textareaRef, nextValue, clampedCaret);
|
|
276
230
|
latestInputValueRef.current = nextValue;
|
|
277
231
|
latestCaretPositionRef.current = clampedCaret;
|
|
278
232
|
setInputValue(nextValue);
|
|
279
233
|
setCaretPosition(clampedCaret);
|
|
280
234
|
onInputActivity?.();
|
|
281
|
-
}, [onInputActivity
|
|
235
|
+
}, [onInputActivity]);
|
|
282
236
|
const setModeToInput = useCallback(() => {
|
|
283
237
|
setMode('input');
|
|
284
238
|
onInputActivity?.();
|
|
@@ -305,14 +259,14 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
305
259
|
if (!text) {
|
|
306
260
|
return;
|
|
307
261
|
}
|
|
308
|
-
const textareaState = safeReadTextarea();
|
|
262
|
+
const textareaState = safeReadTextarea(textareaRef);
|
|
309
263
|
const currentValue = textareaState?.value ?? inputValue;
|
|
310
264
|
const currentCaret = Math.max(0, Math.min(textareaState?.caret ?? caretPosition, currentValue.length));
|
|
311
265
|
const nextValue = currentValue.slice(0, currentCaret) +
|
|
312
266
|
text +
|
|
313
267
|
currentValue.slice(currentCaret);
|
|
314
268
|
setTextareaValue(nextValue, currentCaret + text.length);
|
|
315
|
-
}, [caretPosition, inputValue,
|
|
269
|
+
}, [caretPosition, inputValue, setTextareaValue]);
|
|
316
270
|
const queueAttachment = useCallback((attachment) => {
|
|
317
271
|
setQueuedAttachments((previous) => {
|
|
318
272
|
const nextAttachments = [...previous, attachment];
|
|
@@ -343,27 +297,24 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
343
297
|
mode,
|
|
344
298
|
]);
|
|
345
299
|
const { copyInputToClipboard, handlePastedText, pasteClipboardIntoInput } = clipboardHandlers;
|
|
346
|
-
const submitCurrentSelection =
|
|
347
|
-
let finalValue = mode === 'option' && predefinedOptions.length > 0
|
|
348
|
-
? predefinedOptions[selectedIndex]
|
|
349
|
-
: (safeReadTextarea()?.value ?? inputValue);
|
|
350
|
-
// Replace [Attached file N] placeholders with actual payloads
|
|
351
|
-
queuedAttachments.forEach((attachment, index) => {
|
|
352
|
-
const placeholder = `[Attached file ${index + 1}]`;
|
|
353
|
-
const regex = new RegExp(placeholder.replace(/[[\]]/g, '\\$&'), 'g');
|
|
354
|
-
finalValue = finalValue.replace(regex, attachment.payload);
|
|
355
|
-
});
|
|
356
|
-
onSubmit(questionId, finalValue);
|
|
357
|
-
setQueuedAttachments([]);
|
|
358
|
-
}, [
|
|
359
|
-
inputValue,
|
|
300
|
+
const submitCurrentSelection = useMemo(() => createSubmitHandler({
|
|
360
301
|
mode,
|
|
361
|
-
onSubmit,
|
|
362
302
|
predefinedOptions,
|
|
303
|
+
selectedIndex,
|
|
304
|
+
inputValue,
|
|
363
305
|
queuedAttachments,
|
|
306
|
+
textareaRef,
|
|
307
|
+
onSubmit,
|
|
364
308
|
questionId,
|
|
365
|
-
|
|
309
|
+
setQueuedAttachments,
|
|
310
|
+
}), [
|
|
311
|
+
mode,
|
|
312
|
+
predefinedOptions,
|
|
366
313
|
selectedIndex,
|
|
314
|
+
inputValue,
|
|
315
|
+
queuedAttachments,
|
|
316
|
+
onSubmit,
|
|
317
|
+
questionId,
|
|
367
318
|
]);
|
|
368
319
|
const applySelectedSuggestion = useCallback((targetOverride, suggestionsOverride, selectedIndexOverride) => {
|
|
369
320
|
const target = targetOverride ?? autocompleteTargetRef.current;
|
|
@@ -373,32 +324,26 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
373
324
|
}
|
|
374
325
|
const index = selectedIndexOverride ?? selectedSuggestionIndex;
|
|
375
326
|
const suggestion = availableSuggestions[index] ?? availableSuggestions[0];
|
|
376
|
-
const currentValue = safeReadTextarea()?.value ?? inputValue;
|
|
327
|
+
const currentValue = safeReadTextarea(textareaRef)?.value ?? inputValue;
|
|
377
328
|
const nextValue = currentValue.slice(0, target.start) +
|
|
378
329
|
suggestion +
|
|
379
330
|
currentValue.slice(target.end);
|
|
380
331
|
const nextCaret = target.start + suggestion.length;
|
|
381
332
|
setTextareaValue(nextValue, nextCaret);
|
|
382
|
-
}, [
|
|
383
|
-
fileSuggestions,
|
|
384
|
-
inputValue,
|
|
385
|
-
safeReadTextarea,
|
|
386
|
-
selectedSuggestionIndex,
|
|
387
|
-
setTextareaValue,
|
|
388
|
-
]);
|
|
333
|
+
}, [fileSuggestions, inputValue, selectedSuggestionIndex, setTextareaValue]);
|
|
389
334
|
const insertCharacterInTextarea = useCallback((character) => {
|
|
390
335
|
if (!character) {
|
|
391
336
|
return;
|
|
392
337
|
}
|
|
393
|
-
const currentValue = safeReadTextarea()?.value ?? inputValue;
|
|
338
|
+
const currentValue = safeReadTextarea(textareaRef)?.value ?? inputValue;
|
|
394
339
|
const currentCaret = Math.max(0, Math.min(caretPosition, currentValue.length));
|
|
395
340
|
const nextValue = currentValue.slice(0, currentCaret) +
|
|
396
341
|
character +
|
|
397
342
|
currentValue.slice(currentCaret);
|
|
398
343
|
setTextareaValue(nextValue, currentCaret + character.length);
|
|
399
|
-
}, [caretPosition, inputValue,
|
|
344
|
+
}, [caretPosition, inputValue, setTextareaValue]);
|
|
400
345
|
const handleTextareaSubmit = useCallback(() => {
|
|
401
|
-
const textareaState = safeReadTextarea();
|
|
346
|
+
const textareaState = safeReadTextarea(textareaRef);
|
|
402
347
|
const currentValue = textareaState?.value ?? inputValue;
|
|
403
348
|
const currentCaret = textareaState?.caret ?? caretPosition;
|
|
404
349
|
const currentTarget = mode === 'input'
|
|
@@ -426,105 +371,41 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
426
371
|
safeReadTextarea,
|
|
427
372
|
selectedSuggestionIndex,
|
|
428
373
|
]);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
if (key.name === 'right') {
|
|
469
|
-
setModeToInput();
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
if (key.name === 'left') {
|
|
473
|
-
setModeToOption();
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
if (key.name === 'up' || key.name.toLowerCase() === 'k') {
|
|
477
|
-
setSelectedIndex((previous) => (previous - 1 + predefinedOptions.length) %
|
|
478
|
-
predefinedOptions.length);
|
|
479
|
-
onInputActivity?.();
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
if (key.name === 'down' || key.name.toLowerCase() === 'j') {
|
|
483
|
-
setSelectedIndex((previous) => (previous + 1) % predefinedOptions.length);
|
|
484
|
-
onInputActivity?.();
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
const typedCharacter = isPrintableCharacter(key);
|
|
488
|
-
if (typedCharacter !== null) {
|
|
489
|
-
setModeToInput();
|
|
490
|
-
insertCharacterInTextarea(typedCharacter);
|
|
491
|
-
}
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
if (mode !== 'input' || fileSuggestions.length === 0) {
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (key.name === 'tab') {
|
|
498
|
-
applySelectedSuggestion();
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'n') {
|
|
502
|
-
setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
|
|
503
|
-
onInputActivity?.();
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'p') {
|
|
507
|
-
setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
|
|
508
|
-
onInputActivity?.();
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
if (key.name === 'down') {
|
|
512
|
-
setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
|
|
513
|
-
onInputActivity?.();
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
if (key.name === 'up') {
|
|
517
|
-
setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
|
|
518
|
-
onInputActivity?.();
|
|
519
|
-
}
|
|
520
|
-
});
|
|
521
|
-
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
|
|
522
|
-
? `#search root: ${searchRoot}`
|
|
523
|
-
: '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
|
|
524
|
-
? '#search index: indexing...'
|
|
525
|
-
: `#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' }), _jsx("text", { fg: "gray", children: mode === 'input' && queuedAttachments.length > 0
|
|
526
|
-
? `${inputValue.length} chars + ${queuedAttachments.length} queued`
|
|
527
|
-
: `${inputValue.length} chars` })] }), mode === 'input' && clipboardStatus && (_jsx("text", { fg: clipboardStatus.startsWith('Copy failed:') ? 'red' : 'green', children: clipboardStatus })), mode === 'input' && queuedAttachments.length > 0 && (_jsxs("box", { flexDirection: "column", width: "100%", gap: 0, children: [_jsxs("text", { fg: "yellow", children: [_jsx("strong", { children: "QUEUED ATTACHMENTS" }), " (Delete placeholder text to remove)"] }), queuedAttachments.map((attachment, index) => (_jsxs("text", { fg: "gray", wrapMode: "word", children: ["[File ", index + 1, "] ", attachment.label] }, attachment.id)))] })), mode === 'input' && (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 0, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) })), mode === 'input' && (_jsx("text", { fg: "gray", wrapMode: "word", children: hasOptions
|
|
528
|
-
? '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 • Cmd/Ctrl+V paste/attach'
|
|
529
|
-
: 'Enter/Ctrl+J newline • #search nav: ↑/↓ or Ctrl+N/P • Enter/Tab #search apply • #path for repo file autocomplete • Cmd/Ctrl+C copy • Cmd/Ctrl+V paste/attach' }))] }));
|
|
374
|
+
const keyboardHandler = useMemo(() => createKeyboardRouter({
|
|
375
|
+
mode,
|
|
376
|
+
hasOptions,
|
|
377
|
+
predefinedOptions,
|
|
378
|
+
selectedIndex,
|
|
379
|
+
setSelectedIndex,
|
|
380
|
+
fileSuggestions,
|
|
381
|
+
selectedSuggestionIndex,
|
|
382
|
+
setSelectedSuggestionIndex,
|
|
383
|
+
setModeToInput,
|
|
384
|
+
setModeToOption,
|
|
385
|
+
submitCurrentSelection,
|
|
386
|
+
applySelectedSuggestion,
|
|
387
|
+
insertCharacterInTextarea,
|
|
388
|
+
handlePastedText,
|
|
389
|
+
pasteClipboardIntoInput,
|
|
390
|
+
copyInputToClipboard,
|
|
391
|
+
onInputActivity,
|
|
392
|
+
}), [
|
|
393
|
+
mode,
|
|
394
|
+
hasOptions,
|
|
395
|
+
predefinedOptions,
|
|
396
|
+
selectedIndex,
|
|
397
|
+
fileSuggestions,
|
|
398
|
+
selectedSuggestionIndex,
|
|
399
|
+
setModeToInput,
|
|
400
|
+
setModeToOption,
|
|
401
|
+
submitCurrentSelection,
|
|
402
|
+
applySelectedSuggestion,
|
|
403
|
+
insertCharacterInTextarea,
|
|
404
|
+
handlePastedText,
|
|
405
|
+
pasteClipboardIntoInput,
|
|
406
|
+
copyInputToClipboard,
|
|
407
|
+
onInputActivity,
|
|
408
|
+
]);
|
|
409
|
+
useKeyboard(keyboardHandler);
|
|
410
|
+
return (_jsxs(_Fragment, { children: [_jsx(QuestionBox, { question: question, MarkdownTextComponent: MarkdownText }), _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' && (_jsx(SearchStatus, { isIndexingFiles: isIndexingFiles, repositoryFiles: repositoryFiles, searchRoot: searchRoot, hasSearchRoot: hasSearchRoot })), _jsx(InputStatus, { mode: mode, isNarrow: isNarrow, inputValue: inputValue, queuedAttachments: queuedAttachments }), mode === 'input' && clipboardStatus && (_jsx(ClipboardStatus, { status: clipboardStatus })), mode === 'input' && queuedAttachments.length > 0 && (_jsx(AttachmentsDisplay, { queuedAttachments: queuedAttachments })), mode === 'input' && _jsx(SendButton, {}), mode === 'input' && _jsx(HelpText, { hasOptions: hasOptions })] }));
|
|
530
411
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, } from './keyboard.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a keyboard event handler that routes key events
|
|
4
|
+
* to appropriate actions based on current mode and state.
|
|
5
|
+
*/
|
|
6
|
+
export function createKeyboardRouter(deps) {
|
|
7
|
+
const { mode, hasOptions, predefinedOptions, setSelectedIndex, fileSuggestions, setSelectedSuggestionIndex, setModeToInput, setModeToOption, submitCurrentSelection, applySelectedSuggestion, insertCharacterInTextarea, handlePastedText, pasteClipboardIntoInput, copyInputToClipboard, onInputActivity, } = deps;
|
|
8
|
+
return (key) => {
|
|
9
|
+
// Global shortcuts (work in any mode)
|
|
10
|
+
if (isSubmitShortcut(key)) {
|
|
11
|
+
submitCurrentSelection();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const pastedText = extractPastedText(key);
|
|
15
|
+
if (pastedText !== null) {
|
|
16
|
+
if (mode === 'option' && hasOptions) {
|
|
17
|
+
setModeToInput();
|
|
18
|
+
}
|
|
19
|
+
handlePastedText(pastedText);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (isPasteShortcut(key)) {
|
|
23
|
+
pasteClipboardIntoInput();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (isCopyShortcut(key)) {
|
|
27
|
+
copyInputToClipboard();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Tab/Shift+Tab for mode switching
|
|
31
|
+
if (hasOptions && (isReverseTabShortcut(key) || key.name === 'tab')) {
|
|
32
|
+
if (mode === 'option') {
|
|
33
|
+
setModeToInput();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
setModeToOption();
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Option mode handling
|
|
41
|
+
if (mode === 'option' && hasOptions) {
|
|
42
|
+
const isOptionSubmitKey = key.name === 'enter' ||
|
|
43
|
+
key.name === 'return' ||
|
|
44
|
+
key.sequence === '\r' ||
|
|
45
|
+
key.sequence === '\n';
|
|
46
|
+
if (isOptionSubmitKey) {
|
|
47
|
+
submitCurrentSelection();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (key.name === 'right') {
|
|
51
|
+
setModeToInput();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.name === 'left') {
|
|
55
|
+
setModeToOption();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key.name === 'up' || key.name.toLowerCase() === 'k') {
|
|
59
|
+
setSelectedIndex((previous) => (previous - 1 + predefinedOptions.length) %
|
|
60
|
+
predefinedOptions.length);
|
|
61
|
+
onInputActivity?.();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (key.name === 'down' || key.name.toLowerCase() === 'j') {
|
|
65
|
+
setSelectedIndex((previous) => (previous + 1) % predefinedOptions.length);
|
|
66
|
+
onInputActivity?.();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const typedCharacter = isPrintableCharacter(key);
|
|
70
|
+
if (typedCharacter !== null) {
|
|
71
|
+
setModeToInput();
|
|
72
|
+
insertCharacterInTextarea(typedCharacter);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Input mode with suggestions handling
|
|
77
|
+
if (mode !== 'input' || fileSuggestions.length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (key.name === 'tab') {
|
|
81
|
+
applySelectedSuggestion();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'n') {
|
|
85
|
+
setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
|
|
86
|
+
onInputActivity?.();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'p') {
|
|
90
|
+
setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
|
|
91
|
+
onInputActivity?.();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.name === 'down') {
|
|
95
|
+
setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
|
|
96
|
+
onInputActivity?.();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (key.name === 'up') {
|
|
100
|
+
setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
|
|
101
|
+
onInputActivity?.();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -20,3 +20,18 @@ export const SuggestionsPanel = ({ hasOptions, isIndexingFiles, fileSuggestions,
|
|
|
20
20
|
}, children: "\u2022 VS Code Insiders" })] }))] })) : (_jsx("text", { fg: "gray", children: hasSearchRoot
|
|
21
21
|
? '#search: no matches'
|
|
22
22
|
: '#search: no search root configured' }))] }));
|
|
23
|
+
export const QuestionBox = ({ question, MarkdownTextComponent, }) => (_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(MarkdownTextComponent, { content: question, showCodeCopyControls: true })] }));
|
|
24
|
+
export const SearchStatus = ({ isIndexingFiles, repositoryFiles, searchRoot, hasSearchRoot, }) => (_jsxs("box", { flexDirection: "column", marginBottom: 0, width: "100%", children: [_jsx("text", { fg: "gray", wrapMode: "char", children: hasSearchRoot
|
|
25
|
+
? `#search root: ${searchRoot}`
|
|
26
|
+
: '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
|
|
27
|
+
? '#search index: indexing...'
|
|
28
|
+
: `#search index: ${repositoryFiles.length} files indexed` })] }));
|
|
29
|
+
export const InputStatus = ({ mode, isNarrow, inputValue, queuedAttachments, }) => (_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' }), _jsx("text", { fg: "gray", children: mode === 'input' && queuedAttachments.length > 0
|
|
30
|
+
? `${inputValue.length} chars + ${queuedAttachments.length} queued`
|
|
31
|
+
: `${inputValue.length} chars` })] }));
|
|
32
|
+
export const ClipboardStatus = ({ status }) => (_jsx("text", { fg: status.startsWith('Copy failed:') ? 'red' : 'green', children: status }));
|
|
33
|
+
export const AttachmentsDisplay = ({ queuedAttachments, }) => (_jsxs("box", { flexDirection: "column", width: "100%", gap: 0, children: [_jsxs("text", { fg: "yellow", children: [_jsx("strong", { children: "QUEUED ATTACHMENTS" }), " (Delete placeholder text to remove)"] }), queuedAttachments.map((attachment, index) => (_jsxs("text", { fg: "gray", wrapMode: "word", children: ["[File ", index + 1, "] ", attachment.label] }, attachment.id)))] }));
|
|
34
|
+
export const SendButton = () => (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 0, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) }));
|
|
35
|
+
export const HelpText = ({ hasOptions }) => (_jsx("text", { fg: "gray", wrapMode: "word", children: hasOptions
|
|
36
|
+
? '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 • Cmd/Ctrl+V paste/attach'
|
|
37
|
+
: 'Enter/Ctrl+J newline • #search nav: ↑/↓ or Ctrl+N/P • Enter/Tab #search apply • #path for repo file autocomplete • Cmd/Ctrl+C copy • Cmd/Ctrl+V paste/attach' }));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { safeReadTextarea } from './textarea-operations.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a submit handler that processes the current selection/input
|
|
4
|
+
* and replaces attachment placeholders with actual payloads.
|
|
5
|
+
*/
|
|
6
|
+
export function createSubmitHandler(deps) {
|
|
7
|
+
const { mode, predefinedOptions, selectedIndex, inputValue, queuedAttachments, textareaRef, onSubmit, questionId, setQueuedAttachments, } = deps;
|
|
8
|
+
return () => {
|
|
9
|
+
let finalValue = mode === 'option' && predefinedOptions.length > 0
|
|
10
|
+
? predefinedOptions[selectedIndex]
|
|
11
|
+
: (safeReadTextarea(textareaRef)?.value ?? inputValue);
|
|
12
|
+
// Replace [Attached file N] placeholders with actual payloads
|
|
13
|
+
queuedAttachments.forEach((attachment, index) => {
|
|
14
|
+
const placeholder = `[Attached file ${index + 1}]`;
|
|
15
|
+
const regex = new RegExp(placeholder.replace(/[[\]]/g, '\\$&'), 'g');
|
|
16
|
+
finalValue = finalValue.replace(regex, attachment.payload);
|
|
17
|
+
});
|
|
18
|
+
onSubmit(questionId, finalValue);
|
|
19
|
+
setQueuedAttachments([]);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safely reads text and caret position from textarea.
|
|
3
|
+
* Returns null if textarea is not available or throws an error.
|
|
4
|
+
*/
|
|
5
|
+
export function safeReadTextarea(textareaRef) {
|
|
6
|
+
const textarea = textareaRef.current;
|
|
7
|
+
if (!textarea) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
return {
|
|
12
|
+
value: textarea.plainText,
|
|
13
|
+
caret: textarea.cursorOffset,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Clear the ref if it's stale
|
|
18
|
+
textareaRef.current = null;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Safely writes text and caret position to textarea.
|
|
24
|
+
* Returns true if successful, false otherwise.
|
|
25
|
+
*/
|
|
26
|
+
export function safeWriteTextarea(textareaRef, nextValue, nextCaretPosition) {
|
|
27
|
+
const textarea = textareaRef.current;
|
|
28
|
+
if (!textarea) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
if (textarea.plainText !== nextValue) {
|
|
33
|
+
textarea.setText(nextValue);
|
|
34
|
+
}
|
|
35
|
+
textarea.cursorOffset = nextCaretPosition;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Clear the ref if it's stale
|
|
40
|
+
textareaRef.current = null;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Attempts to focus the textarea.
|
|
46
|
+
* Returns true if successful, false otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export function focusTextarea(textareaRef) {
|
|
49
|
+
const textarea = textareaRef.current;
|
|
50
|
+
if (!textarea) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
textarea.focus?.();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Clear the ref if it's stale
|
|
59
|
+
textareaRef.current = null;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|