@rawwee/interactive-mcp 1.4.0 → 1.4.2

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.
@@ -132,9 +132,6 @@ const App = ({ options: appOptions, onExit }) => {
132
132
  onExit();
133
133
  });
134
134
  }, [onExit, outputFile]);
135
- useEffect(() => {
136
- console.clear();
137
- }, []);
138
135
  useEffect(() => {
139
136
  setFollowInput(false);
140
137
  scrollRef.current?.scrollTo?.({ x: 0, y: 0 });
@@ -208,6 +205,9 @@ async function startUi() {
208
205
  process.exit(1);
209
206
  return;
210
207
  }
208
+ // Clear before the renderer takes over so @opentui starts with a clean slate
209
+ // and its diff engine is in sync with what's on screen.
210
+ console.clear();
211
211
  const renderer = await createCliRenderer({
212
212
  exitOnCtrlC: false,
213
213
  });
@@ -104,9 +104,6 @@ const App = ({ sessionId, title, outputDir, searchRoot, timeoutSeconds, onCloseS
104
104
  setFollowInput(true);
105
105
  scrollRef.current?.scrollTo?.({ x: 0, y: Number.MAX_SAFE_INTEGER });
106
106
  }, []);
107
- useEffect(() => {
108
- console.clear();
109
- }, []);
110
107
  useEffect(() => {
111
108
  let mounted = true;
112
109
  void resolveSearchRoot(searchRoot, { argvEntry: process.argv[1] }).then((resolvedSearchRoot) => {
@@ -234,7 +231,6 @@ const App = ({ sessionId, title, outputDir, searchRoot, timeoutSeconds, onCloseS
234
231
  };
235
232
  }, [timeLeft, currentQuestionId]);
236
233
  const addNewQuestion = async (questionId, questionText, incomingOptions, incomingSearchRoot) => {
237
- console.clear();
238
234
  if (timerRef.current) {
239
235
  clearInterval(timerRef.current);
240
236
  timerRef.current = null;
@@ -289,6 +285,9 @@ const App = ({ sessionId, title, outputDir, searchRoot, timeoutSeconds, onCloseS
289
285
  ?.text || '', questionId: currentQuestionId, predefinedOptions: currentPredefinedOptions, searchRoot: currentSearchRoot, onSubmit: handleSubmit, onInputActivity: keepInputVisible }) }))] }) }), currentQuestionId && timeLeft !== null && (_jsx("box", { paddingLeft: 1, paddingRight: 1, paddingTop: 1, children: _jsx(PromptStatus, { value: percentage, timeLeftSeconds: timeLeft, critical: timeLeft <= 10 }) }))] }));
290
286
  };
291
287
  async function startUi() {
288
+ // Clear before the renderer takes over so @opentui starts with a clean slate
289
+ // and its diff engine is in sync with what's on screen.
290
+ console.clear();
292
291
  const renderer = await createCliRenderer({
293
292
  exitOnCtrlC: false,
294
293
  });
@@ -1,14 +1,17 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
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 { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
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, safeWriteTextarea]);
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, safeReadTextarea]);
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, safeWriteTextarea]);
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, safeReadTextarea, setTextareaValue]);
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 = useCallback(() => {
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
- safeReadTextarea,
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, safeReadTextarea, setTextareaValue]);
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
- useKeyboard((key) => {
430
- if (isSubmitShortcut(key)) {
431
- submitCurrentSelection();
432
- return;
433
- }
434
- const pastedText = extractPastedText(key);
435
- if (pastedText !== null) {
436
- if (mode === 'option' && hasOptions) {
437
- setModeToInput();
438
- }
439
- handlePastedText(pastedText);
440
- return;
441
- }
442
- if (isPasteShortcut(key)) {
443
- pasteClipboardIntoInput();
444
- return;
445
- }
446
- if (isCopyShortcut(key)) {
447
- copyInputToClipboard();
448
- return;
449
- }
450
- if (hasOptions && (isReverseTabShortcut(key) || key.name === 'tab')) {
451
- if (mode === 'option') {
452
- setModeToInput();
453
- }
454
- else {
455
- setModeToOption();
456
- }
457
- return;
458
- }
459
- if (mode === 'option' && hasOptions) {
460
- const isOptionSubmitKey = key.name === 'enter' ||
461
- key.name === 'return' ||
462
- key.sequence === '\r' ||
463
- key.sequence === '\n';
464
- if (isOptionSubmitKey) {
465
- submitCurrentSelection();
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rawwee/interactive-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {