@rawwee/interactive-mcp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +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). 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.
22
23
 
23
24
  ## Usage Scenarios
24
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({
@@ -3,14 +3,14 @@ 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 { isPrintableCharacter, isCopyShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
6
+ import { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
7
7
  import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, } from './interactive-input/sections.js';
8
8
  import { getTextareaDimensions } from './interactive-input/textarea-height.js';
9
9
  import { MarkdownText } from './MarkdownText.js';
10
- import { copyTextToClipboard } from '../utils/clipboard.js';
10
+ import { repositoryFileCache, } from './interactive-input/constants.js';
11
+ import { createClipboardHandlers } from './interactive-input/clipboard-handlers.js';
11
12
  const { useKeyboard } = OpenTuiReact;
12
13
  const { useTerminalDimensions } = OpenTuiReact;
13
- const repositoryFileCache = new Map();
14
14
  export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
15
15
  const [mode, setMode] = useState(predefinedOptions.length > 0 ? 'option' : 'input');
16
16
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -23,6 +23,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
23
23
  const [textareaRenderVersion, setTextareaRenderVersion] = useState(0);
24
24
  const [focusRequestToken, setFocusRequestToken] = useState(0);
25
25
  const [clipboardStatus, setClipboardStatus] = useState(null);
26
+ const [queuedAttachments, setQueuedAttachments] = useState([]);
26
27
  const textareaRef = useRef(null);
27
28
  const latestInputValueRef = useRef(inputValue);
28
29
  const latestCaretPositionRef = useRef(caretPosition);
@@ -128,6 +129,30 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
128
129
  clearTimeout(clearStatusTimeout);
129
130
  };
130
131
  }, [clipboardStatus]);
132
+ // Detect when placeholders are manually removed from input
133
+ const previousInputValueRef = useRef('');
134
+ useEffect(() => {
135
+ if (queuedAttachments.length === 0) {
136
+ previousInputValueRef.current = inputValue;
137
+ return;
138
+ }
139
+ // Only check if input actually changed
140
+ if (inputValue === previousInputValueRef.current) {
141
+ return;
142
+ }
143
+ previousInputValueRef.current = inputValue;
144
+ const currentText = inputValue;
145
+ const missingAttachmentIds = [];
146
+ queuedAttachments.forEach((attachment, index) => {
147
+ const placeholder = `[Attached file ${index + 1}]`;
148
+ if (!currentText.includes(placeholder)) {
149
+ missingAttachmentIds.push(attachment.id);
150
+ }
151
+ });
152
+ if (missingAttachmentIds.length > 0) {
153
+ setQueuedAttachments((prev) => prev.filter((a) => !missingAttachmentIds.includes(a.id)));
154
+ }
155
+ }, [inputValue, queuedAttachments.length]);
131
156
  useEffect(() => {
132
157
  let active = true;
133
158
  const repositoryRoot = searchRoot;
@@ -176,6 +201,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
176
201
  setSelectedIndex(0);
177
202
  setInputValue('');
178
203
  setCaretPosition(0);
204
+ setQueuedAttachments([]);
179
205
  latestInputValueRef.current = '';
180
206
  latestCaretPositionRef.current = 0;
181
207
  setFileSuggestions([]);
@@ -275,44 +301,66 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
275
301
  setMode('option');
276
302
  onInputActivity?.();
277
303
  }, [onInputActivity, predefinedOptions.length]);
278
- const copyInputToClipboard = useCallback(() => {
279
- if (mode !== 'input') {
304
+ const insertTextAtCaret = useCallback((text) => {
305
+ if (!text) {
280
306
  return;
281
307
  }
282
- const textarea = textareaRef.current;
283
- const selectedText = typeof textarea?.hasSelection === 'function' &&
284
- textarea.hasSelection() &&
285
- typeof textarea.getSelectedText === 'function'
286
- ? textarea.getSelectedText()
287
- : '';
288
- const fallbackText = textarea?.plainText ?? inputValue;
289
- const textToCopy = selectedText.length > 0 ? selectedText : fallbackText;
290
- if (textToCopy.length === 0) {
291
- setClipboardStatus('Nothing to copy');
292
- return;
293
- }
294
- void copyTextToClipboard(textToCopy)
295
- .then(() => {
296
- setClipboardStatus('Copied input to clipboard');
297
- })
298
- .catch((error) => {
299
- const errorMessage = error instanceof Error ? error.message : 'unknown error';
300
- setClipboardStatus(`Copy failed: ${errorMessage}`);
308
+ const textareaState = safeReadTextarea();
309
+ const currentValue = textareaState?.value ?? inputValue;
310
+ const currentCaret = Math.max(0, Math.min(textareaState?.caret ?? caretPosition, currentValue.length));
311
+ const nextValue = currentValue.slice(0, currentCaret) +
312
+ text +
313
+ currentValue.slice(currentCaret);
314
+ setTextareaValue(nextValue, currentCaret + text.length);
315
+ }, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
316
+ const queueAttachment = useCallback((attachment) => {
317
+ setQueuedAttachments((previous) => {
318
+ const nextAttachments = [...previous, attachment];
319
+ const attachmentIndex = nextAttachments.length;
320
+ const placeholderText = `[Attached file ${attachmentIndex}]`;
321
+ // Insert placeholder at current caret position
322
+ insertTextAtCaret(placeholderText);
323
+ return nextAttachments;
301
324
  });
302
- onInputActivity?.();
303
- }, [inputValue, mode, onInputActivity]);
325
+ }, [insertTextAtCaret]);
326
+ const clipboardHandlers = useMemo(() => createClipboardHandlers({
327
+ insertTextAtCaret,
328
+ queueAttachment,
329
+ setClipboardStatus,
330
+ onInputActivity,
331
+ searchRoot,
332
+ requestInputFocus,
333
+ textareaRef,
334
+ inputValue,
335
+ mode,
336
+ }), [
337
+ insertTextAtCaret,
338
+ queueAttachment,
339
+ onInputActivity,
340
+ searchRoot,
341
+ requestInputFocus,
342
+ inputValue,
343
+ mode,
344
+ ]);
345
+ const { copyInputToClipboard, handlePastedText, pasteClipboardIntoInput } = clipboardHandlers;
304
346
  const submitCurrentSelection = useCallback(() => {
305
- if (mode === 'option' && predefinedOptions.length > 0) {
306
- onSubmit(questionId, predefinedOptions[selectedIndex]);
307
- return;
308
- }
309
- const textareaValue = safeReadTextarea()?.value ?? inputValue;
310
- onSubmit(questionId, textareaValue);
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([]);
311
358
  }, [
312
359
  inputValue,
313
360
  mode,
314
361
  onSubmit,
315
362
  predefinedOptions,
363
+ queuedAttachments,
316
364
  questionId,
317
365
  safeReadTextarea,
318
366
  selectedIndex,
@@ -383,6 +431,18 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
383
431
  submitCurrentSelection();
384
432
  return;
385
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
+ }
386
446
  if (isCopyShortcut(key)) {
387
447
  copyInputToClipboard();
388
448
  return;
@@ -462,7 +522,9 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
462
522
  ? `#search root: ${searchRoot}`
463
523
  : '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
464
524
  ? '#search index: indexing...'
465
- : `#search index: ${repositoryFiles.length} files indexed` })] })), _jsxs("box", { flexDirection: isNarrow ? 'column' : 'row', justifyContent: "space-between", marginBottom: 0, gap: isNarrow ? 0 : undefined, children: [_jsx("text", { fg: "gray", children: mode === 'input' ? 'Custom input' : 'Option selection' }), _jsxs("text", { fg: "gray", children: [inputValue.length, " chars"] })] }), mode === 'input' && clipboardStatus && (_jsx("text", { fg: clipboardStatus.startsWith('Copy failed:') ? 'red' : 'green', children: clipboardStatus })), mode === 'input' && (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 0, onClick: submitCurrentSelection, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) })), mode === 'input' && (_jsx("text", { fg: "gray", wrapMode: "word", children: hasOptions
466
- ? 'Enter/Ctrl+J newline (or #search apply) • #search nav: ↑/↓ or Ctrl+N/P Tab mode switch • #path for repo file autocomplete • Cmd/Ctrl+C copy'
467
- : 'Enter/Ctrl+J newline #search nav: ↑/↓ or Ctrl+N/P Enter/Tab #search apply #path for repo file autocomplete Cmd/Ctrl+C copy' }))] }));
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' }))] }));
468
530
  }
@@ -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;
@@ -24,6 +24,8 @@ export const isControlKeyShortcut = (key, letter) => {
24
24
  export const isSubmitShortcut = (key) => isControlKeyShortcut(key, 's');
25
25
  export const isCopyShortcut = (key) => isControlKeyShortcut(key, 'c') ||
26
26
  (key.ctrl && key.shift && key.name.toLowerCase() === 'c');
27
+ export const isPasteShortcut = (key) => isControlKeyShortcut(key, 'v') ||
28
+ (key.ctrl && key.shift && key.name.toLowerCase() === 'v');
27
29
  export const isReverseTabShortcut = (key) => key.name === 'backtab' ||
28
30
  (key.name === 'tab' && key.shift) ||
29
31
  key.sequence === '\u001b[Z';
@@ -42,6 +44,19 @@ export const isPrintableCharacter = (key) => {
42
44
  }
43
45
  return null;
44
46
  };
47
+ export const extractPastedText = (key) => {
48
+ if (key.ctrl || key.meta || key.option || key.sequence.length <= 1) {
49
+ return null;
50
+ }
51
+ for (const character of key.sequence) {
52
+ const code = character.charCodeAt(0);
53
+ const isAllowedControl = code === 9 || code === 10 || code === 13;
54
+ if (!isAllowedControl && code < 32) {
55
+ return null;
56
+ }
57
+ }
58
+ return key.sequence;
59
+ };
45
60
  export const textareaKeyBindings = [
46
61
  { name: 's', ctrl: true, action: 'submit' },
47
62
  { name: 's', meta: true, action: 'submit' },
@@ -1,5 +1,40 @@
1
1
  import os from 'os';
2
2
  import { spawn } from 'child_process';
3
+ function decodeFileUri(value) {
4
+ const trimmed = value.trim();
5
+ if (!trimmed.startsWith('file://')) {
6
+ return trimmed;
7
+ }
8
+ return decodeURIComponent(trimmed.replace(/^file:\/\//, ''));
9
+ }
10
+ function normalizePathCandidate(value) {
11
+ const trimmed = value.trim();
12
+ if (!trimmed) {
13
+ return null;
14
+ }
15
+ const decoded = decodeFileUri(trimmed);
16
+ if (decoded.startsWith('/')) {
17
+ return decoded;
18
+ }
19
+ if (/^[a-zA-Z]:[\\/]/.test(decoded)) {
20
+ return decoded;
21
+ }
22
+ return null;
23
+ }
24
+ function parseNonEmptyLines(value) {
25
+ return value
26
+ .split(/\r?\n/)
27
+ .map((line) => line.trim())
28
+ .filter((line) => Boolean(line) && !line.startsWith('#'));
29
+ }
30
+ function parseFilePaths(value) {
31
+ return parseNonEmptyLines(value)
32
+ .map((line) => normalizePathCandidate(line))
33
+ .filter((line) => typeof line === 'string');
34
+ }
35
+ function uniquePaths(values) {
36
+ return Array.from(new Set(values));
37
+ }
3
38
  function runCommand(command, args, input) {
4
39
  return new Promise((resolve, reject) => {
5
40
  const child = spawn(command, args, {
@@ -65,3 +100,152 @@ export async function readTextFromClipboard() {
65
100
  }
66
101
  throw new Error(`Clipboard paste is not supported on platform: ${platform}`);
67
102
  }
103
+ export async function readFilePathsFromClipboard() {
104
+ const platform = os.platform();
105
+ if (platform === 'darwin') {
106
+ const aliasResult = await runCommand('osascript', [
107
+ '-e',
108
+ 'try',
109
+ '-e',
110
+ 'POSIX path of (the clipboard as alias)',
111
+ '-e',
112
+ 'on error',
113
+ '-e',
114
+ 'return ""',
115
+ '-e',
116
+ 'end try',
117
+ ]);
118
+ const aliasPaths = parseFilePaths(aliasResult.stdout);
119
+ if (aliasPaths.length > 0) {
120
+ return aliasPaths;
121
+ }
122
+ const listResult = await runCommand('osascript', [
123
+ '-e',
124
+ 'try',
125
+ '-e',
126
+ 'set L to the clipboard as list',
127
+ '-e',
128
+ 'set out to {}',
129
+ '-e',
130
+ 'repeat with i in L',
131
+ '-e',
132
+ 'set end of out to POSIX path of i',
133
+ '-e',
134
+ 'end repeat',
135
+ '-e',
136
+ 'set text item delimiters of AppleScript to linefeed',
137
+ '-e',
138
+ 'return out as text',
139
+ '-e',
140
+ 'on error',
141
+ '-e',
142
+ 'return ""',
143
+ '-e',
144
+ 'end try',
145
+ ]);
146
+ const listPaths = parseFilePaths(listResult.stdout);
147
+ if (listPaths.length > 0) {
148
+ return listPaths;
149
+ }
150
+ const jxaResult = await runCommand('osascript', [
151
+ '-l',
152
+ 'JavaScript',
153
+ '-e',
154
+ 'ObjC.import("AppKit")',
155
+ '-e',
156
+ 'ObjC.import("Foundation")',
157
+ '-e',
158
+ 'const pb = $.NSPasteboard.generalPasteboard',
159
+ '-e',
160
+ 'const classes = $.NSArray.arrayWithObject($.NSURL)',
161
+ '-e',
162
+ 'const urls = pb.readObjectsForClassesOptions(classes, $())',
163
+ '-e',
164
+ 'if (!urls || urls.count === 0) { "" } else { const out = []; for (let i = 0; i < urls.count; i += 1) { out.push(ObjC.unwrap(urls.objectAtIndex(i).path)); } out.join("\\n"); }',
165
+ ]);
166
+ const jxaPaths = parseFilePaths(jxaResult.stdout);
167
+ if (jxaPaths.length > 0) {
168
+ return uniquePaths(jxaPaths);
169
+ }
170
+ const jxaTypeProbeResult = await runCommand('osascript', [
171
+ '-l',
172
+ 'JavaScript',
173
+ '-e',
174
+ 'ObjC.import("AppKit")',
175
+ '-e',
176
+ 'ObjC.import("Foundation")',
177
+ '-e',
178
+ 'const pb = $.NSPasteboard.generalPasteboard',
179
+ '-e',
180
+ 'const out = []',
181
+ '-e',
182
+ 'const pushLine = (value) => { if (!value) { return; } const text = String(value).trim(); if (!text) { return; } out.push(text); }',
183
+ '-e',
184
+ 'const types = ObjC.deepUnwrap(pb.types) || []',
185
+ '-e',
186
+ 'for (const type of types) { const data = pb.dataForType(type); if (!data || data.isNil()) { continue; } let text = ""; const utf8 = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); if (utf8 && !utf8.isNil()) { text = ObjC.unwrap(utf8); } if (!text) { const utf16 = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF16StringEncoding); if (utf16 && !utf16.isNil()) { text = ObjC.unwrap(utf16); } } if (!text) { continue; } const lines = String(text).split(/\\r?\\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) { continue; } pushLine(trimmed); } }',
187
+ '-e',
188
+ 'out.join("\\n")',
189
+ ]);
190
+ return uniquePaths(parseFilePaths(jxaTypeProbeResult.stdout));
191
+ }
192
+ if (platform === 'linux') {
193
+ try {
194
+ const uriListResult = await runCommand('xclip', [
195
+ '-selection',
196
+ 'clipboard',
197
+ '-o',
198
+ '-t',
199
+ 'text/uri-list',
200
+ ]);
201
+ return parseFilePaths(uriListResult.stdout);
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
207
+ if (platform === 'win32') {
208
+ try {
209
+ const fileDropResult = await runCommand('powershell', [
210
+ '-NoProfile',
211
+ '-Command',
212
+ '$paths = Get-Clipboard -Format FileDropList; if ($paths) { $paths | ForEach-Object { $_.FullName } }',
213
+ ]);
214
+ return parseFilePaths(fileDropResult.stdout);
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ return [];
221
+ }
222
+ export async function readImageDataUrlFromClipboard() {
223
+ const platform = os.platform();
224
+ if (platform !== 'darwin') {
225
+ return null;
226
+ }
227
+ try {
228
+ const imageResult = await runCommand('osascript', [
229
+ '-l',
230
+ 'JavaScript',
231
+ '-e',
232
+ 'ObjC.import("AppKit")',
233
+ '-e',
234
+ 'ObjC.import("Foundation")',
235
+ '-e',
236
+ 'const pb = $.NSPasteboard.generalPasteboard',
237
+ '-e',
238
+ 'const image = $.NSImage.alloc.initWithPasteboard(pb)',
239
+ '-e',
240
+ 'if (!image || image.isNil()) { "" } else { const tiff = image.TIFFRepresentation; if (!tiff || tiff.isNil()) { "" } else { const rep = $.NSBitmapImageRep.imageRepWithData(tiff); if (!rep || rep.isNil()) { "" } else { const pngData = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $()); if (!pngData || pngData.isNil()) { "" } else { ObjC.unwrap(pngData.base64EncodedStringWithOptions(0)); } } } }',
241
+ ]);
242
+ const base64Payload = imageResult.stdout.trim();
243
+ if (!base64Payload) {
244
+ return null;
245
+ }
246
+ return `data:image/png;base64,${base64Payload}`;
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rawwee/interactive-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {