@rawwee/interactive-mcp 1.3.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/README.md CHANGED
@@ -19,7 +19,7 @@ This server exposes the following tools via the Model Context Protocol (MCP):
19
19
  - `stop_intensive_chat`: Closes an active intensive chat session.
20
20
 
21
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
- In TUI input mode, `Cmd/Ctrl+V` supports clipboard text plus file/image includes from pasted paths, copied file objects, and copied images (platform support varies).
22
+ In TUI input mode, `Cmd/Ctrl+V` supports clipboard text plus file/image includes from pasted paths, copied file objects, and copied images (platform support varies). Files are sent as path references rather than full contents to optimize token usage—the AI can then read files directly using its available tools.
23
23
 
24
24
  ## Usage Scenarios
25
25
 
@@ -282,11 +282,11 @@ const App = ({ sessionId, title, outputDir, searchRoot, timeoutSeconds, onCloseS
282
282
  : 0;
283
283
  return (_jsxs("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "black", paddingLeft: isNarrow ? 0 : 1, paddingRight: isNarrow ? 0 : 1, children: [_jsxs("box", { marginBottom: 1, flexDirection: "column", width: "100%", paddingLeft: 1, paddingRight: 1, gap: 0, children: [_jsx("text", { fg: "magenta", children: _jsx("strong", { children: title }) }), _jsxs("text", { fg: "gray", wrapMode: "word", children: ["Session ", sessionId] }), !isNarrow && _jsx("text", { fg: "gray", children: "Waiting for prompts\u2026" })] }), _jsx("scrollbox", { ref: scrollRef, flexGrow: 1, width: "100%", scrollY: true, stickyScroll: followInput, stickyStart: followInput ? 'bottom' : undefined, viewportCulling: false, scrollbarOptions: {
284
284
  showArrows: false,
285
- }, children: _jsxs("box", { flexDirection: "column", width: "100%", paddingBottom: 1, children: [_jsx("box", { flexDirection: "column", width: "100%", gap: 2, children: chatHistory.map((msg, i) => (_jsxs("box", { flexDirection: "column", width: "100%", paddingLeft: 1, paddingRight: 1, gap: 1, children: [msg.isQuestion ? (_jsxs("box", { flexDirection: "column", width: "100%", gap: 0, children: [_jsx("text", { fg: "cyan", children: _jsx("strong", { children: "QUESTION" }) }), _jsx("box", { paddingLeft: isNarrow ? 1 : 2, children: _jsx(MarkdownText, { content: msg.text, showCodeCopyControls: true }) })] })) : null, msg.answer ? (_jsxs("box", { flexDirection: "column", width: "100%", marginTop: 0, children: [_jsx("text", { fg: "green", children: _jsx("strong", { children: "ANSWER" }) }), _jsx("box", { paddingLeft: isNarrow ? 1 : 2, children: _jsx(MarkdownText, { content: msg.answer, showCodeCopyControls: true }) })] })) : null] }, `msg-${i}`))) }), currentQuestionId && (_jsxs("box", { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, gap: 1, children: [_jsx(InteractiveInput, { question: chatHistory
286
- .slice()
287
- .reverse()
288
- .find((m) => m.isQuestion && !m.answer)
289
- ?.text || '', questionId: currentQuestionId, predefinedOptions: currentPredefinedOptions, searchRoot: currentSearchRoot, onSubmit: handleSubmit, onInputActivity: keepInputVisible }), timeLeft !== null && (_jsx("box", { marginTop: 0, children: _jsx(PromptStatus, { value: percentage, timeLeftSeconds: timeLeft, critical: timeLeft <= 10 }) }))] }))] }) })] }));
285
+ }, children: _jsxs("box", { flexDirection: "column", width: "100%", paddingBottom: 1, children: [_jsx("box", { flexDirection: "column", width: "100%", gap: 2, children: chatHistory.map((msg, i) => (_jsxs("box", { flexDirection: "column", width: "100%", paddingLeft: 1, paddingRight: 1, gap: 1, children: [msg.isQuestion ? (_jsxs("box", { flexDirection: "column", width: "100%", gap: 0, children: [_jsx("text", { fg: "cyan", children: _jsx("strong", { children: "QUESTION" }) }), _jsx("box", { paddingLeft: isNarrow ? 1 : 2, children: _jsx(MarkdownText, { content: msg.text, showCodeCopyControls: true }) })] })) : null, msg.answer ? (_jsxs("box", { flexDirection: "column", width: "100%", marginTop: 0, children: [_jsx("text", { fg: "green", children: _jsx("strong", { children: "ANSWER" }) }), _jsx("box", { paddingLeft: isNarrow ? 1 : 2, children: _jsx(MarkdownText, { content: msg.answer, showCodeCopyControls: true }) })] })) : null] }, `msg-${i}`))) }), currentQuestionId && (_jsx("box", { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, gap: 1, children: _jsx(InteractiveInput, { question: chatHistory
286
+ .slice()
287
+ .reverse()
288
+ .find((m) => m.isQuestion && !m.answer)
289
+ ?.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
290
  };
291
291
  async function startUi() {
292
292
  const renderer = await createCliRenderer({
@@ -1,143 +1,19 @@
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
- import crypto from 'node:crypto';
5
- import fs from 'node:fs/promises';
6
4
  import path from 'node:path';
7
5
  import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
8
- import { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
9
- 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';
10
8
  import { getTextareaDimensions } from './interactive-input/textarea-height.js';
11
9
  import { MarkdownText } from './MarkdownText.js';
12
- import { copyTextToClipboard, readFilePathsFromClipboard, readImageDataUrlFromClipboard, readTextFromClipboard, } from '../utils/clipboard.js';
10
+ import { repositoryFileCache, } from './interactive-input/constants.js';
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';
13
15
  const { useKeyboard } = OpenTuiReact;
14
16
  const { useTerminalDimensions } = OpenTuiReact;
15
- const repositoryFileCache = new Map();
16
- const IMAGE_MIME_BY_EXTENSION = {
17
- '.png': 'image/png',
18
- '.jpg': 'image/jpeg',
19
- '.jpeg': 'image/jpeg',
20
- '.gif': 'image/gif',
21
- '.webp': 'image/webp',
22
- '.bmp': 'image/bmp',
23
- '.svg': 'image/svg+xml',
24
- };
25
- const IMAGE_EMBED_MAX_BYTES = 2 * 1024 * 1024;
26
- const TEXT_EMBED_MAX_BYTES = 512 * 1024;
27
- const TEXT_EMBED_MAX_CHARS = 20000;
28
- const COLLAPSE_TEXT_PASTE_CHARS = 800;
29
- const COLLAPSE_TEXT_PASTE_LINES = 12;
30
- const inferCodeFenceLanguage = (fileName) => {
31
- const extension = path.extname(fileName).slice(1).toLowerCase();
32
- const mapping = {
33
- js: 'javascript',
34
- jsx: 'jsx',
35
- ts: 'typescript',
36
- tsx: 'tsx',
37
- json: 'json',
38
- md: 'markdown',
39
- py: 'python',
40
- sh: 'bash',
41
- yml: 'yaml',
42
- yaml: 'yaml',
43
- html: 'html',
44
- css: 'css',
45
- go: 'go',
46
- rs: 'rust',
47
- java: 'java',
48
- cs: 'csharp',
49
- };
50
- return mapping[extension] ?? '';
51
- };
52
- const normalizeClipboardPath = (clipboardText, searchRoot) => {
53
- const trimmed = clipboardText.trim();
54
- if (!trimmed) {
55
- return null;
56
- }
57
- const lines = trimmed
58
- .split(/\r?\n/)
59
- .map((line) => line.trim())
60
- .filter(Boolean);
61
- let candidate = lines[0] ?? '';
62
- if (!candidate) {
63
- return null;
64
- }
65
- if ((candidate.startsWith('"') && candidate.endsWith('"')) ||
66
- (candidate.startsWith("'") && candidate.endsWith("'"))) {
67
- candidate = candidate.slice(1, -1);
68
- }
69
- if (candidate.startsWith('file://')) {
70
- candidate = decodeURIComponent(candidate.replace(/^file:\/\//, ''));
71
- }
72
- if (path.isAbsolute(candidate)) {
73
- return candidate;
74
- }
75
- if (!searchRoot) {
76
- return null;
77
- }
78
- return path.resolve(searchRoot, candidate);
79
- };
80
- const isLikelyTextBuffer = (buffer) => {
81
- if (buffer.includes(0)) {
82
- return false;
83
- }
84
- const sample = buffer.subarray(0, Math.min(buffer.length, 2048));
85
- let suspiciousBytes = 0;
86
- for (const byte of sample) {
87
- const isPrintableAscii = byte >= 32 && byte <= 126;
88
- const isWhitespace = byte === 9 || byte === 10 || byte === 13;
89
- const isExtendedUtf8Byte = byte >= 128;
90
- if (!isPrintableAscii && !isWhitespace && !isExtendedUtf8Byte) {
91
- suspiciousBytes += 1;
92
- }
93
- }
94
- return suspiciousBytes < 8;
95
- };
96
- const shouldCollapsePastedText = (text) => text.length >= COLLAPSE_TEXT_PASTE_CHARS ||
97
- text.split(/\r?\n/).length >= COLLAPSE_TEXT_PASTE_LINES;
98
- const buildAttachmentFromPath = async (absolutePath) => {
99
- const fileStats = await fs.stat(absolutePath);
100
- if (!fileStats.isFile()) {
101
- throw new Error('Clipboard path is not a file');
102
- }
103
- const fileName = path.basename(absolutePath);
104
- const extension = path.extname(fileName).toLowerCase();
105
- const imageMimeType = IMAGE_MIME_BY_EXTENSION[extension];
106
- if (imageMimeType) {
107
- if (fileStats.size > IMAGE_EMBED_MAX_BYTES) {
108
- return {
109
- id: crypto.randomUUID(),
110
- label: `Image: ${fileName} (${Math.round(fileStats.size / 1024)}KB, too large to embed)`,
111
- payload: `[Image attachment: ${fileName}] (${imageMimeType}, ${fileStats.size} bytes, too large to embed)`,
112
- };
113
- }
114
- const imageBuffer = await fs.readFile(absolutePath);
115
- return {
116
- id: crypto.randomUUID(),
117
- label: `Image: ${fileName}`,
118
- payload: `![${fileName}](data:${imageMimeType};base64,${imageBuffer.toString('base64')})`,
119
- };
120
- }
121
- const fileBuffer = await fs.readFile(absolutePath);
122
- if (fileStats.size <= TEXT_EMBED_MAX_BYTES &&
123
- isLikelyTextBuffer(fileBuffer)) {
124
- const fileText = fileBuffer.toString('utf8');
125
- const truncatedText = fileText.length > TEXT_EMBED_MAX_CHARS
126
- ? `${fileText.slice(0, TEXT_EMBED_MAX_CHARS)}\n\n...[truncated ${fileText.length - TEXT_EMBED_MAX_CHARS} chars]`
127
- : fileText;
128
- const language = inferCodeFenceLanguage(fileName);
129
- return {
130
- id: crypto.randomUUID(),
131
- label: `File: ${fileName} (${truncatedText.length} chars)`,
132
- payload: `Attached file: ${fileName}\n\`\`\`${language}\n${truncatedText}\n\`\`\``,
133
- };
134
- }
135
- return {
136
- id: crypto.randomUUID(),
137
- label: `File: ${fileName} (${Math.round(fileStats.size / 1024)}KB binary)`,
138
- payload: `[File attachment: ${fileName}] (${fileStats.size} bytes, binary content omitted)`,
139
- };
140
- };
141
17
  export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
142
18
  const [mode, setMode] = useState(predefinedOptions.length > 0 ? 'option' : 'input');
143
19
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -192,53 +68,6 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
192
68
  : `/${normalizedPath}`;
193
69
  return `vscode://file${encodeURI(vscodePath)}`;
194
70
  }, [searchRoot, selectedSuggestion]);
195
- const safeReadTextarea = useCallback(() => {
196
- const textarea = textareaRef.current;
197
- if (!textarea) {
198
- return null;
199
- }
200
- try {
201
- return {
202
- value: textarea.plainText,
203
- caret: textarea.cursorOffset,
204
- };
205
- }
206
- catch {
207
- textareaRef.current = null;
208
- return null;
209
- }
210
- }, []);
211
- const safeWriteTextarea = useCallback((nextValue, nextCaretPosition) => {
212
- const textarea = textareaRef.current;
213
- if (!textarea) {
214
- return false;
215
- }
216
- try {
217
- if (textarea.plainText !== nextValue) {
218
- textarea.setText(nextValue);
219
- }
220
- textarea.cursorOffset = nextCaretPosition;
221
- return true;
222
- }
223
- catch {
224
- textareaRef.current = null;
225
- return false;
226
- }
227
- }, []);
228
- const focusTextarea = useCallback(() => {
229
- const textarea = textareaRef.current;
230
- if (!textarea) {
231
- return false;
232
- }
233
- try {
234
- textarea.focus?.();
235
- return true;
236
- }
237
- catch {
238
- textareaRef.current = null;
239
- return false;
240
- }
241
- }, []);
242
71
  useEffect(() => {
243
72
  latestInputValueRef.current = inputValue;
244
73
  }, [inputValue]);
@@ -256,6 +85,30 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
256
85
  clearTimeout(clearStatusTimeout);
257
86
  };
258
87
  }, [clipboardStatus]);
88
+ // Detect when placeholders are manually removed from input
89
+ const previousInputValueRef = useRef('');
90
+ useEffect(() => {
91
+ if (queuedAttachments.length === 0) {
92
+ previousInputValueRef.current = inputValue;
93
+ return;
94
+ }
95
+ // Only check if input actually changed
96
+ if (inputValue === previousInputValueRef.current) {
97
+ return;
98
+ }
99
+ previousInputValueRef.current = inputValue;
100
+ const currentText = inputValue;
101
+ const missingAttachmentIds = [];
102
+ queuedAttachments.forEach((attachment, index) => {
103
+ const placeholder = `[Attached file ${index + 1}]`;
104
+ if (!currentText.includes(placeholder)) {
105
+ missingAttachmentIds.push(attachment.id);
106
+ }
107
+ });
108
+ if (missingAttachmentIds.length > 0) {
109
+ setQueuedAttachments((prev) => prev.filter((a) => !missingAttachmentIds.includes(a.id)));
110
+ }
111
+ }, [inputValue, queuedAttachments.length]);
259
112
  useEffect(() => {
260
113
  let active = true;
261
114
  const repositoryRoot = searchRoot;
@@ -309,29 +162,27 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
309
162
  latestCaretPositionRef.current = 0;
310
163
  setFileSuggestions([]);
311
164
  setSelectedSuggestionIndex(0);
312
- safeWriteTextarea('', 0);
313
- }, [predefinedOptions.length, questionId, safeWriteTextarea]);
165
+ safeWriteTextarea(textareaRef, '', 0);
166
+ }, [predefinedOptions.length, questionId]);
314
167
  useEffect(() => {
315
168
  if (mode !== 'input') {
316
169
  return;
317
170
  }
318
171
  const nextValue = latestInputValueRef.current;
319
172
  const clampedCaret = Math.max(0, Math.min(latestCaretPositionRef.current, nextValue.length));
320
- const didWrite = safeWriteTextarea(nextValue, clampedCaret);
173
+ const didWrite = safeWriteTextarea(textareaRef, nextValue, clampedCaret);
321
174
  if (!didWrite) {
322
175
  setTextareaRenderVersion((previous) => previous + 1);
323
176
  return;
324
177
  }
325
- if (!focusTextarea()) {
178
+ if (!focusTextarea(textareaRef)) {
326
179
  setTextareaRenderVersion((previous) => previous + 1);
327
180
  }
328
181
  }, [
329
182
  focusRequestToken,
330
- focusTextarea,
331
183
  height,
332
184
  mode,
333
185
  questionId,
334
- safeWriteTextarea,
335
186
  textareaRenderVersion,
336
187
  width,
337
188
  ]);
@@ -356,7 +207,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
356
207
  : Math.min(previous, nextSuggestions.length - 1));
357
208
  }, [caretPosition, inputValue, mode, repositoryFiles]);
358
209
  const syncInputStateFromTextarea = useCallback(() => {
359
- const textareaState = safeReadTextarea();
210
+ const textareaState = safeReadTextarea(textareaRef);
360
211
  if (!textareaState) {
361
212
  return;
362
213
  }
@@ -372,16 +223,16 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
372
223
  setInputValue(nextValue);
373
224
  setCaretPosition(nextCaret);
374
225
  onInputActivity?.();
375
- }, [onInputActivity, safeReadTextarea]);
226
+ }, [onInputActivity]);
376
227
  const setTextareaValue = useCallback((nextValue, nextCaretPosition) => {
377
228
  const clampedCaret = Math.max(0, Math.min(nextCaretPosition, nextValue.length));
378
- safeWriteTextarea(nextValue, clampedCaret);
229
+ safeWriteTextarea(textareaRef, nextValue, clampedCaret);
379
230
  latestInputValueRef.current = nextValue;
380
231
  latestCaretPositionRef.current = clampedCaret;
381
232
  setInputValue(nextValue);
382
233
  setCaretPosition(clampedCaret);
383
234
  onInputActivity?.();
384
- }, [onInputActivity, safeWriteTextarea]);
235
+ }, [onInputActivity]);
385
236
  const setModeToInput = useCallback(() => {
386
237
  setMode('input');
387
238
  onInputActivity?.();
@@ -404,152 +255,66 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
404
255
  setMode('option');
405
256
  onInputActivity?.();
406
257
  }, [onInputActivity, predefinedOptions.length]);
407
- const copyInputToClipboard = useCallback(() => {
408
- if (mode !== 'input') {
409
- return;
410
- }
411
- const textarea = textareaRef.current;
412
- const selectedText = typeof textarea?.hasSelection === 'function' &&
413
- textarea.hasSelection() &&
414
- typeof textarea.getSelectedText === 'function'
415
- ? textarea.getSelectedText()
416
- : '';
417
- const fallbackText = textarea?.plainText ?? inputValue;
418
- const textToCopy = selectedText.length > 0 ? selectedText : fallbackText;
419
- if (textToCopy.length === 0) {
420
- setClipboardStatus('Nothing to copy');
421
- return;
422
- }
423
- void copyTextToClipboard(textToCopy)
424
- .then(() => {
425
- setClipboardStatus('Copied input to clipboard');
426
- })
427
- .catch((error) => {
428
- const errorMessage = error instanceof Error ? error.message : 'unknown error';
429
- setClipboardStatus(`Copy failed: ${errorMessage}`);
430
- });
431
- onInputActivity?.();
432
- }, [inputValue, mode, onInputActivity]);
433
258
  const insertTextAtCaret = useCallback((text) => {
434
259
  if (!text) {
435
260
  return;
436
261
  }
437
- const textareaState = safeReadTextarea();
262
+ const textareaState = safeReadTextarea(textareaRef);
438
263
  const currentValue = textareaState?.value ?? inputValue;
439
264
  const currentCaret = Math.max(0, Math.min(textareaState?.caret ?? caretPosition, currentValue.length));
440
265
  const nextValue = currentValue.slice(0, currentCaret) +
441
266
  text +
442
267
  currentValue.slice(currentCaret);
443
268
  setTextareaValue(nextValue, currentCaret + text.length);
444
- }, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
269
+ }, [caretPosition, inputValue, setTextareaValue]);
445
270
  const queueAttachment = useCallback((attachment) => {
446
- setQueuedAttachments((previous) => [...previous, attachment]);
447
- }, []);
448
- const handlePastedText = useCallback((pastedText) => {
449
- if (!pastedText) {
450
- return;
451
- }
452
- const pasteAsPlainText = () => {
453
- insertTextAtCaret(pastedText);
454
- setClipboardStatus('Pasted text');
455
- onInputActivity?.();
456
- };
457
- const normalizedPath = normalizeClipboardPath(pastedText, searchRoot);
458
- if (normalizedPath) {
459
- void buildAttachmentFromPath(normalizedPath)
460
- .then((attachment) => {
461
- queueAttachment(attachment);
462
- setClipboardStatus(`Queued ${attachment.label}`);
463
- onInputActivity?.();
464
- })
465
- .catch(() => {
466
- pasteAsPlainText();
467
- });
468
- return;
469
- }
470
- if (shouldCollapsePastedText(pastedText)) {
471
- queueAttachment({
472
- id: crypto.randomUUID(),
473
- label: `Pasted text block (${pastedText.length} chars)`,
474
- payload: pastedText,
475
- });
476
- setClipboardStatus('Queued pasted text block');
477
- onInputActivity?.();
478
- return;
479
- }
480
- pasteAsPlainText();
481
- }, [insertTextAtCaret, onInputActivity, queueAttachment, searchRoot]);
482
- const pasteClipboardIntoInput = useCallback(() => {
483
- requestInputFocus();
484
- void readTextFromClipboard()
485
- .then(async (clipboardText) => {
486
- if (clipboardText.trim()) {
487
- handlePastedText(clipboardText);
488
- return;
489
- }
490
- const clipboardPaths = await readFilePathsFromClipboard();
491
- if (clipboardPaths.length > 0) {
492
- const attachments = await Promise.all(clipboardPaths.map(async (clipboardPath) => {
493
- try {
494
- return await buildAttachmentFromPath(clipboardPath);
495
- }
496
- catch {
497
- return null;
498
- }
499
- }));
500
- const validAttachments = attachments.filter((value) => value !== null);
501
- if (validAttachments.length > 0) {
502
- validAttachments.forEach((attachment) => {
503
- queueAttachment(attachment);
504
- });
505
- setClipboardStatus(validAttachments.length === 1
506
- ? `Queued ${validAttachments[0].label}`
507
- : `Queued ${validAttachments.length} clipboard files`);
508
- onInputActivity?.();
509
- return;
510
- }
511
- }
512
- const imageDataUrl = await readImageDataUrlFromClipboard();
513
- if (imageDataUrl) {
514
- queueAttachment({
515
- id: crypto.randomUUID(),
516
- label: 'Image: pasted-image.png',
517
- payload: `![pasted-image.png](${imageDataUrl})`,
518
- });
519
- setClipboardStatus('Queued clipboard image');
520
- onInputActivity?.();
521
- return;
522
- }
523
- setClipboardStatus('Paste failed: clipboard is empty');
524
- })
525
- .catch((error) => {
526
- const errorMessage = error instanceof Error ? error.message : 'unknown error';
527
- setClipboardStatus(`Paste failed: ${errorMessage}`);
271
+ setQueuedAttachments((previous) => {
272
+ const nextAttachments = [...previous, attachment];
273
+ const attachmentIndex = nextAttachments.length;
274
+ const placeholderText = `[Attached file ${attachmentIndex}]`;
275
+ // Insert placeholder at current caret position
276
+ insertTextAtCaret(placeholderText);
277
+ return nextAttachments;
528
278
  });
529
- }, [handlePastedText, onInputActivity, queueAttachment, requestInputFocus]);
530
- const submitCurrentSelection = useCallback(() => {
531
- const baseValue = mode === 'option' && predefinedOptions.length > 0
532
- ? predefinedOptions[selectedIndex]
533
- : (safeReadTextarea()?.value ?? inputValue);
534
- const attachmentPayload = queuedAttachments
535
- .map((attachment) => attachment.payload)
536
- .join('\n\n');
537
- const finalValue = attachmentPayload
538
- ? baseValue.trim().length > 0
539
- ? `${baseValue}\n\n${attachmentPayload}`
540
- : attachmentPayload
541
- : baseValue;
542
- onSubmit(questionId, finalValue);
543
- setQueuedAttachments([]);
544
- }, [
279
+ }, [insertTextAtCaret]);
280
+ const clipboardHandlers = useMemo(() => createClipboardHandlers({
281
+ insertTextAtCaret,
282
+ queueAttachment,
283
+ setClipboardStatus,
284
+ onInputActivity,
285
+ searchRoot,
286
+ requestInputFocus,
287
+ textareaRef,
545
288
  inputValue,
546
289
  mode,
547
- onSubmit,
290
+ }), [
291
+ insertTextAtCaret,
292
+ queueAttachment,
293
+ onInputActivity,
294
+ searchRoot,
295
+ requestInputFocus,
296
+ inputValue,
297
+ mode,
298
+ ]);
299
+ const { copyInputToClipboard, handlePastedText, pasteClipboardIntoInput } = clipboardHandlers;
300
+ const submitCurrentSelection = useMemo(() => createSubmitHandler({
301
+ mode,
548
302
  predefinedOptions,
303
+ selectedIndex,
304
+ inputValue,
549
305
  queuedAttachments,
306
+ textareaRef,
307
+ onSubmit,
550
308
  questionId,
551
- safeReadTextarea,
309
+ setQueuedAttachments,
310
+ }), [
311
+ mode,
312
+ predefinedOptions,
552
313
  selectedIndex,
314
+ inputValue,
315
+ queuedAttachments,
316
+ onSubmit,
317
+ questionId,
553
318
  ]);
554
319
  const applySelectedSuggestion = useCallback((targetOverride, suggestionsOverride, selectedIndexOverride) => {
555
320
  const target = targetOverride ?? autocompleteTargetRef.current;
@@ -559,32 +324,26 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
559
324
  }
560
325
  const index = selectedIndexOverride ?? selectedSuggestionIndex;
561
326
  const suggestion = availableSuggestions[index] ?? availableSuggestions[0];
562
- const currentValue = safeReadTextarea()?.value ?? inputValue;
327
+ const currentValue = safeReadTextarea(textareaRef)?.value ?? inputValue;
563
328
  const nextValue = currentValue.slice(0, target.start) +
564
329
  suggestion +
565
330
  currentValue.slice(target.end);
566
331
  const nextCaret = target.start + suggestion.length;
567
332
  setTextareaValue(nextValue, nextCaret);
568
- }, [
569
- fileSuggestions,
570
- inputValue,
571
- safeReadTextarea,
572
- selectedSuggestionIndex,
573
- setTextareaValue,
574
- ]);
333
+ }, [fileSuggestions, inputValue, selectedSuggestionIndex, setTextareaValue]);
575
334
  const insertCharacterInTextarea = useCallback((character) => {
576
335
  if (!character) {
577
336
  return;
578
337
  }
579
- const currentValue = safeReadTextarea()?.value ?? inputValue;
338
+ const currentValue = safeReadTextarea(textareaRef)?.value ?? inputValue;
580
339
  const currentCaret = Math.max(0, Math.min(caretPosition, currentValue.length));
581
340
  const nextValue = currentValue.slice(0, currentCaret) +
582
341
  character +
583
342
  currentValue.slice(currentCaret);
584
343
  setTextareaValue(nextValue, currentCaret + character.length);
585
- }, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
344
+ }, [caretPosition, inputValue, setTextareaValue]);
586
345
  const handleTextareaSubmit = useCallback(() => {
587
- const textareaState = safeReadTextarea();
346
+ const textareaState = safeReadTextarea(textareaRef);
588
347
  const currentValue = textareaState?.value ?? inputValue;
589
348
  const currentCaret = textareaState?.caret ?? caretPosition;
590
349
  const currentTarget = mode === 'input'
@@ -612,105 +371,41 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
612
371
  safeReadTextarea,
613
372
  selectedSuggestionIndex,
614
373
  ]);
615
- useKeyboard((key) => {
616
- if (isSubmitShortcut(key)) {
617
- submitCurrentSelection();
618
- return;
619
- }
620
- const pastedText = extractPastedText(key);
621
- if (pastedText !== null) {
622
- if (mode === 'option' && hasOptions) {
623
- setModeToInput();
624
- }
625
- handlePastedText(pastedText);
626
- return;
627
- }
628
- if (isPasteShortcut(key)) {
629
- pasteClipboardIntoInput();
630
- return;
631
- }
632
- if (isCopyShortcut(key)) {
633
- copyInputToClipboard();
634
- return;
635
- }
636
- if (hasOptions && (isReverseTabShortcut(key) || key.name === 'tab')) {
637
- if (mode === 'option') {
638
- setModeToInput();
639
- }
640
- else {
641
- setModeToOption();
642
- }
643
- return;
644
- }
645
- if (mode === 'option' && hasOptions) {
646
- const isOptionSubmitKey = key.name === 'enter' ||
647
- key.name === 'return' ||
648
- key.sequence === '\r' ||
649
- key.sequence === '\n';
650
- if (isOptionSubmitKey) {
651
- submitCurrentSelection();
652
- return;
653
- }
654
- if (key.name === 'right') {
655
- setModeToInput();
656
- return;
657
- }
658
- if (key.name === 'left') {
659
- setModeToOption();
660
- return;
661
- }
662
- if (key.name === 'up' || key.name.toLowerCase() === 'k') {
663
- setSelectedIndex((previous) => (previous - 1 + predefinedOptions.length) %
664
- predefinedOptions.length);
665
- onInputActivity?.();
666
- return;
667
- }
668
- if (key.name === 'down' || key.name.toLowerCase() === 'j') {
669
- setSelectedIndex((previous) => (previous + 1) % predefinedOptions.length);
670
- onInputActivity?.();
671
- return;
672
- }
673
- const typedCharacter = isPrintableCharacter(key);
674
- if (typedCharacter !== null) {
675
- setModeToInput();
676
- insertCharacterInTextarea(typedCharacter);
677
- }
678
- return;
679
- }
680
- if (mode !== 'input' || fileSuggestions.length === 0) {
681
- return;
682
- }
683
- if (key.name === 'tab') {
684
- applySelectedSuggestion();
685
- return;
686
- }
687
- if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'n') {
688
- setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
689
- onInputActivity?.();
690
- return;
691
- }
692
- if ((key.ctrl || key.meta) && key.name.toLowerCase() === 'p') {
693
- setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
694
- onInputActivity?.();
695
- return;
696
- }
697
- if (key.name === 'down') {
698
- setSelectedSuggestionIndex((previous) => (previous + 1) % fileSuggestions.length);
699
- onInputActivity?.();
700
- return;
701
- }
702
- if (key.name === 'up') {
703
- setSelectedSuggestionIndex((previous) => previous <= 0 ? fileSuggestions.length - 1 : previous - 1);
704
- onInputActivity?.();
705
- }
706
- });
707
- 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
708
- ? `#search root: ${searchRoot}`
709
- : '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
710
- ? '#search index: indexing...'
711
- : `#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
712
- ? `${inputValue.length} chars + ${queuedAttachments.length} queued`
713
- : `${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%", children: [_jsx("text", { fg: "yellow", children: _jsx("strong", { children: "QUEUED ATTACHMENTS" }) }), queuedAttachments.map((attachment) => (_jsxs("text", { fg: "gray", wrapMode: "word", children: ["- ", attachment.label] }, attachment.id)))] })), 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
714
- ? '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'
715
- : '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 })] }));
716
411
  }
@@ -283,14 +283,14 @@ export function MarkdownText({ content, streaming = false, showContentCopyContro
283
283
  return (_jsx("box", { height: 1 }, `segment-${index}-line-${lineIndex}`));
284
284
  }
285
285
  const inlineSegments = parseMarkdownInlineLinks(line);
286
- return (_jsx("box", { flexDirection: "row", width: "100%", children: inlineSegments.flatMap((inlineSegment, inlineSegmentIndex) => {
286
+ return (_jsx("box", { flexDirection: "row", flexWrap: "wrap", width: "100%", children: inlineSegments.flatMap((inlineSegment, inlineSegmentIndex) => {
287
287
  const baseKey = `segment-${index}-line-${lineIndex}-part-${inlineSegmentIndex}`;
288
288
  if (inlineSegment.type !== 'link' ||
289
289
  !inlineSegment.href) {
290
290
  return (_jsx("text", { wrapMode: "word", children: inlineSegment.value }, baseKey));
291
291
  }
292
292
  if (!isVscodeFileLink(inlineSegment.href)) {
293
- return (_jsx("text", { fg: "cyan", wrapMode: "word", onMouseUp: () => {
293
+ return (_jsx("text", { fg: "cyan", wrapMode: "char", onMouseUp: () => {
294
294
  void openLinkWithHint(inlineSegment.href ?? '', 'default');
295
295
  }, children: inlineSegment.value }, baseKey));
296
296
  }
@@ -1,46 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
2
  import * as OpenTuiReact from '@opentui/react';
3
- import { useEffect, useState } from 'react';
4
3
  import { TextProgressBar } from './TextProgressBar.js';
5
4
  const { useTerminalDimensions } = OpenTuiReact;
6
- const { useTimeline } = OpenTuiReact;
7
5
  export function PromptStatus({ value, timeLeftSeconds, critical, }) {
8
6
  const { width } = useTerminalDimensions();
9
- const [pulseLevel, setPulseLevel] = useState(0);
10
- const timeline = useTimeline({ duration: 900, loop: true, autoplay: false });
11
7
  const suffixLength = ` • ${timeLeftSeconds}s left`.length;
12
8
  const availableWidth = Math.max(16, width - 4);
13
9
  const reservedWidth = 2 + 1 + 4 + suffixLength;
14
10
  const computedBarWidth = availableWidth - reservedWidth;
15
11
  const barWidth = Math.max(6, Math.min(28, computedBarWidth));
16
12
  const shortcutHint = width < 80 ? '⌃S send • ⇧↹ mode' : '⌃S send • ⇥ mode • ⇧↹ reverse';
17
- useEffect(() => {
18
- if (!critical) {
19
- timeline.pause();
20
- timeline.resetItems();
21
- setPulseLevel(0);
22
- return;
23
- }
24
- const pulseTarget = { level: 0 };
25
- timeline.resetItems();
26
- timeline.add(pulseTarget, {
27
- level: 1,
28
- duration: 900,
29
- ease: 'inOutSine',
30
- loop: true,
31
- alternate: true,
32
- onUpdate: (animation) => {
33
- const nextValue = animation.targets[0]?.level;
34
- if (typeof nextValue === 'number') {
35
- setPulseLevel(nextValue);
36
- }
37
- },
38
- });
39
- timeline.play();
40
- return () => {
41
- timeline.pause();
42
- timeline.resetItems();
43
- };
44
- }, [critical, timeline]);
45
- return (_jsxs("box", { flexDirection: "column", alignItems: "flex-start", width: "100%", gap: 0, children: [_jsx("text", { fg: "gray", wrapMode: "word", children: shortcutHint }), _jsxs("text", { fg: critical && pulseLevel > 0.5 ? 'red' : 'gray', children: [critical ? '●' : '○', " ", timeLeftSeconds, "s remaining"] }), _jsx(TextProgressBar, { value: value, width: barWidth, timeLeftSeconds: timeLeftSeconds, critical: critical })] }));
13
+ return (_jsxs("box", { flexDirection: "column", alignItems: "flex-start", width: "100%", children: [_jsx("text", { fg: "gray", wrapMode: "word", children: shortcutHint }), _jsx(TextProgressBar, { value: value, width: barWidth, timeLeftSeconds: timeLeftSeconds, critical: critical })] }));
46
14
  }
@@ -0,0 +1,79 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { IMAGE_MIME_BY_EXTENSION, TEXT_EMBED_MAX_BYTES, } from './constants.js';
5
+ export const normalizeClipboardPath = (clipboardText, searchRoot) => {
6
+ const trimmed = clipboardText.trim();
7
+ if (!trimmed) {
8
+ return null;
9
+ }
10
+ const lines = trimmed
11
+ .split(/\r?\n/)
12
+ .map((line) => line.trim())
13
+ .filter(Boolean);
14
+ let candidate = lines[0] ?? '';
15
+ if (!candidate) {
16
+ return null;
17
+ }
18
+ if ((candidate.startsWith('"') && candidate.endsWith('"')) ||
19
+ (candidate.startsWith("'") && candidate.endsWith("'"))) {
20
+ candidate = candidate.slice(1, -1);
21
+ }
22
+ if (candidate.startsWith('file://')) {
23
+ candidate = decodeURIComponent(candidate.replace(/^file:\/\//, ''));
24
+ }
25
+ if (path.isAbsolute(candidate)) {
26
+ return candidate;
27
+ }
28
+ if (!searchRoot) {
29
+ return null;
30
+ }
31
+ return path.resolve(searchRoot, candidate);
32
+ };
33
+ const isLikelyTextBuffer = (buffer) => {
34
+ if (buffer.includes(0)) {
35
+ return false;
36
+ }
37
+ const sample = buffer.subarray(0, Math.min(buffer.length, 2048));
38
+ let suspiciousBytes = 0;
39
+ for (const byte of sample) {
40
+ const isPrintableAscii = byte >= 32 && byte <= 126;
41
+ const isWhitespace = byte === 9 || byte === 10 || byte === 13;
42
+ const isExtendedUtf8Byte = byte >= 128;
43
+ if (!isPrintableAscii && !isWhitespace && !isExtendedUtf8Byte) {
44
+ suspiciousBytes += 1;
45
+ }
46
+ }
47
+ return suspiciousBytes < 8;
48
+ };
49
+ export const buildAttachmentFromPath = async (absolutePath) => {
50
+ const fileStats = await fs.stat(absolutePath);
51
+ if (!fileStats.isFile()) {
52
+ throw new Error('Clipboard path is not a file');
53
+ }
54
+ const fileName = path.basename(absolutePath);
55
+ const extension = path.extname(fileName).toLowerCase();
56
+ const imageMimeType = IMAGE_MIME_BY_EXTENSION[extension];
57
+ const sizeKB = Math.round(fileStats.size / 1024);
58
+ if (imageMimeType) {
59
+ return {
60
+ id: crypto.randomUUID(),
61
+ label: `Image: ${fileName} (${sizeKB}KB)`,
62
+ payload: `[Image file: ${absolutePath}]`,
63
+ };
64
+ }
65
+ const fileBuffer = await fs.readFile(absolutePath);
66
+ const isTextFile = fileStats.size <= TEXT_EMBED_MAX_BYTES && isLikelyTextBuffer(fileBuffer);
67
+ if (isTextFile) {
68
+ return {
69
+ id: crypto.randomUUID(),
70
+ label: `File: ${fileName} (${sizeKB}KB text)`,
71
+ payload: `[Text file: ${absolutePath}]`,
72
+ };
73
+ }
74
+ return {
75
+ id: crypto.randomUUID(),
76
+ label: `File: ${fileName} (${sizeKB}KB binary)`,
77
+ payload: `[Binary file: ${absolutePath}]`,
78
+ };
79
+ };
@@ -0,0 +1,134 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { copyTextToClipboard as copyToSystemClipboard, readFilePathsFromClipboard, readImageDataUrlFromClipboard, readTextFromClipboard, } from '../../utils/clipboard.js';
6
+ import { buildAttachmentFromPath, normalizeClipboardPath, } from './attachments.js';
7
+ import { shouldCollapsePastedText, } from './constants.js';
8
+ export function createClipboardHandlers(deps) {
9
+ const { insertTextAtCaret, queueAttachment, setClipboardStatus, onInputActivity, searchRoot, requestInputFocus, textareaRef, inputValue, mode, } = deps;
10
+ const handlePastedText = (pastedText) => {
11
+ if (!pastedText) {
12
+ return;
13
+ }
14
+ const pasteAsPlainText = () => {
15
+ insertTextAtCaret(pastedText);
16
+ setClipboardStatus('Pasted text');
17
+ onInputActivity?.();
18
+ };
19
+ const normalizedPath = normalizeClipboardPath(pastedText, searchRoot);
20
+ if (normalizedPath) {
21
+ void buildAttachmentFromPath(normalizedPath)
22
+ .then((attachment) => {
23
+ queueAttachment(attachment);
24
+ setClipboardStatus(`Queued ${attachment.label}`);
25
+ onInputActivity?.();
26
+ })
27
+ .catch(() => {
28
+ pasteAsPlainText();
29
+ });
30
+ return;
31
+ }
32
+ if (shouldCollapsePastedText(pastedText)) {
33
+ queueAttachment({
34
+ id: crypto.randomUUID(),
35
+ label: `Pasted text block (${pastedText.length} chars)`,
36
+ payload: pastedText,
37
+ });
38
+ setClipboardStatus('Queued pasted text block');
39
+ onInputActivity?.();
40
+ return;
41
+ }
42
+ pasteAsPlainText();
43
+ };
44
+ const pasteClipboardIntoInput = () => {
45
+ requestInputFocus();
46
+ void readTextFromClipboard()
47
+ .then(async (clipboardText) => {
48
+ if (clipboardText.trim()) {
49
+ handlePastedText(clipboardText);
50
+ return;
51
+ }
52
+ const clipboardPaths = await readFilePathsFromClipboard();
53
+ if (clipboardPaths.length > 0) {
54
+ const attachments = await Promise.all(clipboardPaths.map(async (clipboardPath) => {
55
+ try {
56
+ return await buildAttachmentFromPath(clipboardPath);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }));
62
+ const validAttachments = attachments.filter((value) => value !== null);
63
+ if (validAttachments.length > 0) {
64
+ validAttachments.forEach((attachment) => {
65
+ queueAttachment(attachment);
66
+ });
67
+ setClipboardStatus(validAttachments.length === 1
68
+ ? `Queued ${validAttachments[0].label}`
69
+ : `Queued ${validAttachments.length} clipboard files`);
70
+ onInputActivity?.();
71
+ return;
72
+ }
73
+ }
74
+ const imageDataUrl = await readImageDataUrlFromClipboard();
75
+ if (imageDataUrl) {
76
+ try {
77
+ // Extract base64 data and save to temp file
78
+ const base64Data = imageDataUrl.replace(/^data:image\/\w+;base64,/, '');
79
+ const imageBuffer = Buffer.from(base64Data, 'base64');
80
+ const tempDir = os.tmpdir();
81
+ const tempFileName = `pasted-image-${crypto.randomUUID()}.png`;
82
+ const tempFilePath = path.join(tempDir, tempFileName);
83
+ await fs.writeFile(tempFilePath, imageBuffer);
84
+ // Now queue it as a file attachment
85
+ const attachment = await buildAttachmentFromPath(tempFilePath);
86
+ queueAttachment(attachment);
87
+ setClipboardStatus('Queued clipboard image');
88
+ onInputActivity?.();
89
+ }
90
+ catch (error) {
91
+ const errorMessage = error instanceof Error ? error.message : 'unknown error';
92
+ setClipboardStatus(`Failed to save clipboard image: ${errorMessage}`);
93
+ }
94
+ return;
95
+ }
96
+ setClipboardStatus('Paste failed: clipboard is empty');
97
+ })
98
+ .catch((error) => {
99
+ const errorMessage = error instanceof Error ? error.message : 'unknown error';
100
+ setClipboardStatus(`Paste failed: ${errorMessage}`);
101
+ });
102
+ };
103
+ const copyInputToClipboard = () => {
104
+ if (mode !== 'input') {
105
+ return;
106
+ }
107
+ const textarea = textareaRef.current;
108
+ const selectedText = typeof textarea?.hasSelection === 'function' &&
109
+ textarea.hasSelection() &&
110
+ typeof textarea?.getSelectedText === 'function'
111
+ ? textarea.getSelectedText()
112
+ : '';
113
+ const fallbackText = textarea?.plainText ?? inputValue;
114
+ const textToCopy = selectedText.length > 0 ? selectedText : fallbackText;
115
+ if (textToCopy.length === 0) {
116
+ setClipboardStatus('Nothing to copy');
117
+ return;
118
+ }
119
+ void copyToSystemClipboard(textToCopy)
120
+ .then(() => {
121
+ setClipboardStatus('Copied input to clipboard');
122
+ })
123
+ .catch((error) => {
124
+ const errorMessage = error instanceof Error ? error.message : 'unknown error';
125
+ setClipboardStatus(`Copy failed: ${errorMessage}`);
126
+ });
127
+ onInputActivity?.();
128
+ };
129
+ return {
130
+ handlePastedText,
131
+ pasteClipboardIntoInput,
132
+ copyInputToClipboard,
133
+ };
134
+ }
@@ -0,0 +1,15 @@
1
+ export const repositoryFileCache = new Map();
2
+ export const IMAGE_MIME_BY_EXTENSION = {
3
+ '.png': 'image/png',
4
+ '.jpg': 'image/jpeg',
5
+ '.jpeg': 'image/jpeg',
6
+ '.gif': 'image/gif',
7
+ '.webp': 'image/webp',
8
+ '.bmp': 'image/bmp',
9
+ '.svg': 'image/svg+xml',
10
+ };
11
+ export const TEXT_EMBED_MAX_BYTES = 512 * 1024;
12
+ export const COLLAPSE_TEXT_PASTE_CHARS = 800;
13
+ export const COLLAPSE_TEXT_PASTE_LINES = 12;
14
+ export const shouldCollapsePastedText = (text) => text.length >= COLLAPSE_TEXT_PASTE_CHARS ||
15
+ text.split(/\r?\n/).length >= COLLAPSE_TEXT_PASTE_LINES;
@@ -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.3.0",
3
+ "version": "1.4.1",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {