diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 51ab068f..2bed05ee 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -90,6 +90,7 @@ import { buildAttachmentCitationText, findAttachmentCitationRanges, } from './attachmentCitations'; +import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import type { Part } from '@opencode-ai/sdk/v2/client'; const MAX_VISIBLE_TEXTAREA_LINES = 8; @@ -128,6 +129,38 @@ const buildImagePasteInsertion = (pastedText: string, citationText: string): str return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`; }; +const getInsertedTextFromChange = (previousValue: string, nextValue: string): string => { + if (previousValue === nextValue) { + return ''; + } + + let prefixLength = 0; + while ( + prefixLength < previousValue.length + && prefixLength < nextValue.length + && previousValue[prefixLength] === nextValue[prefixLength] + ) { + prefixLength += 1; + } + + let previousSuffix = previousValue.length; + let nextSuffix = nextValue.length; + while ( + previousSuffix > prefixLength + && nextSuffix > prefixLength + && previousValue[previousSuffix - 1] === nextValue[nextSuffix - 1] + ) { + previousSuffix -= 1; + nextSuffix -= 1; + } + + return nextValue.slice(prefixLength, nextSuffix); +}; + +const getFileMentionInputSourceForInsertedText = (insertedText: string): FileMentionAutocompleteInputSource => ( + insertedText.includes('@') ? 'paste' : 'manual' +); + const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => { if (!content) { return content; @@ -963,6 +996,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const dragEnterCountRef = React.useRef(0); const suppressNextFileDropTextInsertRef = React.useRef(false); const suppressNextFileDropTextInsertTimeoutRef = React.useRef | null>(null); + const suppressNextFileMentionPasteRef = React.useRef(false); + const suppressNextFileMentionPasteTimeoutRef = React.useRef | null>(null); const pendingDroppedAbsolutePathsRef = React.useRef([]); const canAcceptDropRef = React.useRef(false); const mentionRef = React.useRef(null); @@ -2700,7 +2735,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo adjustTextareaHeight({ allowShrink }); }, [adjustTextareaHeight, message, isMobile]); - const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { + const updateAutocompleteState = React.useCallback(( + value: string, + cursorPosition: number, + inputSource: FileMentionAutocompleteInputSource = 'manual', + insertedText?: string, + ) => { if (inputMode === 'shell') { setShowCommandAutocomplete(false); setShowFileMention(false); @@ -2765,19 +2805,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setShowSnippetAutocomplete(false); - const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); - if (lastAtSymbol !== -1) { - const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; - const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); - const isWordBoundary = !charBefore || /\s/.test(charBefore); - if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { - setMentionQuery(textAfterAt); - setShowFileMention(true); - } else { - setShowFileMention(false); - } - } else { + const nextMentionQuery = getFileMentionAutocompleteQuery({ value, cursorPosition, inputSource, insertedText }); + if (nextMentionQuery === null) { setShowFileMention(false); + } else { + setMentionQuery(nextMentionQuery); + setShowFileMention(true); } }, [ inputMode, @@ -2791,7 +2824,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setSnippetQuery, ]); - const insertTextAtSelection = React.useCallback((text: string) => { + const insertTextAtSelection = React.useCallback(( + text: string, + inputSource: FileMentionAutocompleteInputSource = 'manual', + ) => { if (!text) { return; } @@ -2800,7 +2836,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!textarea) { const nextValue = message + text; setMessage(nextValue); - updateAutocompleteState(nextValue, nextValue.length); + updateAutocompleteState(nextValue, nextValue.length, inputSource, text); requestAnimationFrame(() => adjustTextareaHeight()); return; } @@ -2820,7 +2856,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo adjustTextareaHeight(); }); - updateAutocompleteState(nextValue, cursorPosition); + updateAutocompleteState(nextValue, cursorPosition, inputSource, text); }, [adjustTextareaHeight, message, updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { @@ -2841,6 +2877,25 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, 700); }, [clearDropTextSuppression]); + const clearFileMentionPasteSuppression = React.useCallback(() => { + suppressNextFileMentionPasteRef.current = false; + if (suppressNextFileMentionPasteTimeoutRef.current) { + clearTimeout(suppressNextFileMentionPasteTimeoutRef.current); + suppressNextFileMentionPasteTimeoutRef.current = null; + } + }, []); + + const markFileMentionPasteSuppression = React.useCallback(() => { + suppressNextFileMentionPasteRef.current = true; + if (suppressNextFileMentionPasteTimeoutRef.current) { + clearTimeout(suppressNextFileMentionPasteTimeoutRef.current); + } + suppressNextFileMentionPasteTimeoutRef.current = setTimeout(() => { + suppressNextFileMentionPasteRef.current = false; + suppressNextFileMentionPasteTimeoutRef.current = null; + }, 700); + }, []); + const handleBeforeInput = React.useCallback((e: React.FormEvent) => { if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) { return; @@ -2868,6 +2923,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const value = e.target.value; const cursorPosition = e.target.selectionStart ?? value.length; + const pastedInsertedText = nativeInputEvent?.inputType?.startsWith('insertFromPaste') + ? getInsertedTextFromChange(messageRef.current, value) + : ''; + const isPasteInput = pastedInsertedText.includes('@') || suppressNextFileMentionPasteRef.current; + if (suppressNextFileMentionPasteRef.current) { + clearFileMentionPasteSuppression(); + } + const inputSource: FileMentionAutocompleteInputSource = isPasteInput + ? 'paste' + : 'manual'; if (inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); @@ -2889,14 +2954,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(value); adjustTextareaHeight(); - updateAutocompleteState(value, cursorPosition); + updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText); }; React.useEffect(() => { return () => { clearDropTextSuppression(); + clearFileMentionPasteSuppression(); }; - }, [clearDropTextSuppression]); + }, [clearDropTextSuppression, clearFileMentionPasteSuppression]); const handlePaste = React.useCallback(async (e: React.ClipboardEvent) => { // Pasting a URL over a selection wraps it as a markdown link: @@ -2927,7 +2993,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } adjustTextareaHeight(); }); - updateAutocompleteState(next, caret); + updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url); return; } } @@ -2951,17 +3017,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); const imageFiles = Array.from(fileMap.values()); + const pastedText = e.clipboardData.getData('text'); if (imageFiles.length === 0) { + if (pastedText.includes('@')) { + markFileMentionPasteSuppression(); + } return; } if (!currentSessionId && !newSessionDraftOpen) { + if (pastedText.includes('@')) { + markFileMentionPasteSuppression(); + } return; } e.preventDefault(); - const pastedText = e.clipboardData.getData('text'); const assignedFilenames = assignImageAttachmentFilenames( imageFiles, [ @@ -2979,7 +3051,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo message.slice(selectionEnd), ); - insertTextAtSelection(insertionText); + insertTextAtSelection(insertionText, getFileMentionInputSourceForInsertedText(insertionText)); for (let index = 0; index < imageFiles.length; index += 1) { const filename = assignedFilenames[index]; @@ -2994,7 +3066,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { diff --git a/packages/ui/src/components/chat/__tests__/fileMentionAutocompleteState.test.ts b/packages/ui/src/components/chat/__tests__/fileMentionAutocompleteState.test.ts new file mode 100644 index 00000000..6b52bb4e --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/fileMentionAutocompleteState.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test'; + +import { getFileMentionAutocompleteQuery } from '../fileMentionAutocompleteState'; + +describe('getFileMentionAutocompleteQuery', () => { + test('opens file mention autocomplete for manually typed boundary @ text', () => { + expect(getFileMentionAutocompleteQuery({ + value: '@config', + cursorPosition: '@config'.length, + inputSource: 'manual', + })).toBe('config'); + + expect(getFileMentionAutocompleteQuery({ + value: 'check @main.ts', + cursorPosition: 'check @main.ts'.length, + inputSource: 'manual', + })).toBe('main.ts'); + + expect(getFileMentionAutocompleteQuery({ + value: 'check @docs', + cursorPosition: 'check @docs'.length, + })).toBe('docs'); + }); + + test('does not open file mention autocomplete when pasted text contains @', () => { + const pastedValues = [ + '@config', + '@/path/to/file', + 'Use @main.ts', + ]; + + for (const value of pastedValues) { + expect(getFileMentionAutocompleteQuery({ + value, + cursorPosition: value.length, + inputSource: 'paste', + insertedText: value, + })).toBeNull(); + } + }); + + test('does not open file mention autocomplete for pasted package and email text', () => { + const pastedValues = [ + 'user@email.com', + 'npx @scope/pkg@latest', + ]; + + for (const value of pastedValues) { + expect(getFileMentionAutocompleteQuery({ + value, + cursorPosition: value.length, + inputSource: 'paste', + insertedText: value, + })).toBeNull(); + } + }); + + test('keeps autocomplete open when pasting a query fragment after a manually typed @', () => { + expect(getFileMentionAutocompleteQuery({ + value: '@config', + cursorPosition: '@config'.length, + inputSource: 'paste', + insertedText: 'config', + })).toBe('config'); + }); + + test('uses current value when paste source lacks inserted text context', () => { + expect(getFileMentionAutocompleteQuery({ + value: '@config', + cursorPosition: '@config'.length, + inputSource: 'paste', + })).toBe('config'); + }); +}); diff --git a/packages/ui/src/components/chat/fileMentionAutocompleteState.ts b/packages/ui/src/components/chat/fileMentionAutocompleteState.ts new file mode 100644 index 00000000..ce983773 --- /dev/null +++ b/packages/ui/src/components/chat/fileMentionAutocompleteState.ts @@ -0,0 +1,32 @@ +export type FileMentionAutocompleteInputSource = 'manual' | 'paste'; + +export const getFileMentionAutocompleteQuery = ({ + value, + cursorPosition, + inputSource = 'manual', + insertedText, +}: { + value: string; + cursorPosition: number; + inputSource?: FileMentionAutocompleteInputSource; + insertedText?: string; +}): string | null => { + if (inputSource === 'paste' && insertedText?.includes('@')) { + return null; + } + + const textBeforeCursor = value.substring(0, cursorPosition); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + if (lastAtSymbol === -1) { + return null; + } + + const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; + const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); + const isWordBoundary = !charBefore || /\s/.test(charBefore); + if (!isWordBoundary || textAfterAt.includes(' ') || textAfterAt.includes('\n')) { + return null; + } + + return textAfterAt; +};