@rawwee/interactive-mcp 1.3.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 +1 -1
- package/dist/commands/intensive-chat/ui.js +5 -5
- package/dist/components/InteractiveInput.js +62 -248
- package/dist/components/MarkdownText.js +2 -2
- package/dist/components/PromptStatus.js +1 -33
- package/dist/components/interactive-input/attachments.js +79 -0
- package/dist/components/interactive-input/clipboard-handlers.js +134 -0
- package/dist/components/interactive-input/constants.js +15 -0
- package/package.json +1 -1
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 && (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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,16 @@
|
|
|
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';
|
|
6
4
|
import path from 'node:path';
|
|
7
5
|
import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
|
|
8
6
|
import { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
|
|
9
7
|
import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, } from './interactive-input/sections.js';
|
|
10
8
|
import { getTextareaDimensions } from './interactive-input/textarea-height.js';
|
|
11
9
|
import { MarkdownText } from './MarkdownText.js';
|
|
12
|
-
import {
|
|
10
|
+
import { repositoryFileCache, } from './interactive-input/constants.js';
|
|
11
|
+
import { createClipboardHandlers } from './interactive-input/clipboard-handlers.js';
|
|
13
12
|
const { useKeyboard } = OpenTuiReact;
|
|
14
13
|
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: `})`,
|
|
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
14
|
export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
|
|
142
15
|
const [mode, setMode] = useState(predefinedOptions.length > 0 ? 'option' : 'input');
|
|
143
16
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
@@ -256,6 +129,30 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
256
129
|
clearTimeout(clearStatusTimeout);
|
|
257
130
|
};
|
|
258
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]);
|
|
259
156
|
useEffect(() => {
|
|
260
157
|
let active = true;
|
|
261
158
|
const repositoryRoot = searchRoot;
|
|
@@ -404,32 +301,6 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
404
301
|
setMode('option');
|
|
405
302
|
onInputActivity?.();
|
|
406
303
|
}, [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
304
|
const insertTextAtCaret = useCallback((text) => {
|
|
434
305
|
if (!text) {
|
|
435
306
|
return;
|
|
@@ -443,102 +314,45 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
443
314
|
setTextareaValue(nextValue, currentCaret + text.length);
|
|
444
315
|
}, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
|
|
445
316
|
const queueAttachment = useCallback((attachment) => {
|
|
446
|
-
setQueuedAttachments((previous) =>
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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: ``,
|
|
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}`);
|
|
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;
|
|
528
324
|
});
|
|
529
|
-
}, [
|
|
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;
|
|
530
346
|
const submitCurrentSelection = useCallback(() => {
|
|
531
|
-
|
|
347
|
+
let finalValue = mode === 'option' && predefinedOptions.length > 0
|
|
532
348
|
? predefinedOptions[selectedIndex]
|
|
533
349
|
: (safeReadTextarea()?.value ?? inputValue);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
: attachmentPayload
|
|
541
|
-
: baseValue;
|
|
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
|
+
});
|
|
542
356
|
onSubmit(questionId, finalValue);
|
|
543
357
|
setQueuedAttachments([]);
|
|
544
358
|
}, [
|
|
@@ -710,7 +524,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
|
|
|
710
524
|
? '#search index: indexing...'
|
|
711
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
|
|
712
526
|
? `${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: [
|
|
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
|
|
714
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'
|
|
715
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' }))] }));
|
|
716
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: "
|
|
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
|
-
|
|
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;
|