@rawwee/interactive-mcp 1.2.0 → 1.3.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).
22
23
 
23
24
  ## Usage Scenarios
24
25
 
@@ -1,16 +1,143 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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';
4
6
  import path from 'node:path';
5
7
  import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
6
- import { isPrintableCharacter, isCopyShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
8
+ import { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
7
9
  import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, } from './interactive-input/sections.js';
8
10
  import { getTextareaDimensions } from './interactive-input/textarea-height.js';
9
11
  import { MarkdownText } from './MarkdownText.js';
10
- import { copyTextToClipboard } from '../utils/clipboard.js';
12
+ import { copyTextToClipboard, readFilePathsFromClipboard, readImageDataUrlFromClipboard, readTextFromClipboard, } from '../utils/clipboard.js';
11
13
  const { useKeyboard } = OpenTuiReact;
12
14
  const { useTerminalDimensions } = OpenTuiReact;
13
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
+ };
14
141
  export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
15
142
  const [mode, setMode] = useState(predefinedOptions.length > 0 ? 'option' : 'input');
16
143
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -23,6 +150,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
23
150
  const [textareaRenderVersion, setTextareaRenderVersion] = useState(0);
24
151
  const [focusRequestToken, setFocusRequestToken] = useState(0);
25
152
  const [clipboardStatus, setClipboardStatus] = useState(null);
153
+ const [queuedAttachments, setQueuedAttachments] = useState([]);
26
154
  const textareaRef = useRef(null);
27
155
  const latestInputValueRef = useRef(inputValue);
28
156
  const latestCaretPositionRef = useRef(caretPosition);
@@ -176,6 +304,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
176
304
  setSelectedIndex(0);
177
305
  setInputValue('');
178
306
  setCaretPosition(0);
307
+ setQueuedAttachments([]);
179
308
  latestInputValueRef.current = '';
180
309
  latestCaretPositionRef.current = 0;
181
310
  setFileSuggestions([]);
@@ -301,18 +430,123 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
301
430
  });
302
431
  onInputActivity?.();
303
432
  }, [inputValue, mode, onInputActivity]);
304
- const submitCurrentSelection = useCallback(() => {
305
- if (mode === 'option' && predefinedOptions.length > 0) {
306
- onSubmit(questionId, predefinedOptions[selectedIndex]);
433
+ const insertTextAtCaret = useCallback((text) => {
434
+ if (!text) {
435
+ return;
436
+ }
437
+ const textareaState = safeReadTextarea();
438
+ const currentValue = textareaState?.value ?? inputValue;
439
+ const currentCaret = Math.max(0, Math.min(textareaState?.caret ?? caretPosition, currentValue.length));
440
+ const nextValue = currentValue.slice(0, currentCaret) +
441
+ text +
442
+ currentValue.slice(currentCaret);
443
+ setTextareaValue(nextValue, currentCaret + text.length);
444
+ }, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
445
+ 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?.();
307
478
  return;
308
479
  }
309
- const textareaValue = safeReadTextarea()?.value ?? inputValue;
310
- onSubmit(questionId, textareaValue);
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}`);
528
+ });
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([]);
311
544
  }, [
312
545
  inputValue,
313
546
  mode,
314
547
  onSubmit,
315
548
  predefinedOptions,
549
+ queuedAttachments,
316
550
  questionId,
317
551
  safeReadTextarea,
318
552
  selectedIndex,
@@ -383,6 +617,18 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
383
617
  submitCurrentSelection();
384
618
  return;
385
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
+ }
386
632
  if (isCopyShortcut(key)) {
387
633
  copyInputToClipboard();
388
634
  return;
@@ -462,7 +708,9 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
462
708
  ? `#search root: ${searchRoot}`
463
709
  : '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
464
710
  ? '#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' }))] }));
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' }))] }));
468
716
  }
@@ -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.3.0",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {