import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import DynamicTextArea from "react-textarea-autosize" import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions" import { useExtensionState } from "../../context/ExtensionStateContext" import { ContextMenuOptionType, getContextMenuOptions, insertMention, removeMention, shouldShowContextMenu, } from "../../utils/context-mentions" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" import Thumbnails from "../common/Thumbnails" interface ChatTextAreaProps { inputValue: string setInputValue: (value: string) => void textAreaDisabled: boolean placeholderText: string selectedImages: string[] setSelectedImages: React.Dispatch> onSend: () => void onSelectImages: () => void shouldDisableImages: boolean onHeightChange?: (height: number) => void } const ChatTextArea = forwardRef( ( { inputValue, setInputValue, textAreaDisabled, placeholderText, selectedImages, setSelectedImages, onSend, onSelectImages, shouldDisableImages, onHeightChange, }, ref ) => { const { filePaths } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) const [showContextMenu, setShowContextMenu] = useState(false) const [cursorPosition, setCursorPosition] = useState(0) const [searchQuery, setSearchQuery] = useState("") const textAreaRef = useRef(null) const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false) const highlightLayerRef = useRef(null) const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1) const [selectedType, setSelectedType] = useState(null) const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(null) const queryItems = useMemo(() => { return [ { type: ContextMenuOptionType.Problems, value: "problems" }, ...filePaths .map((file) => "/" + file) .map((path) => ({ type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File, value: path, })), ] }, [filePaths]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( contextMenuContainerRef.current && !contextMenuContainerRef.current.contains(event.target as Node) ) { setShowContextMenu(false) } } if (showContextMenu) { document.addEventListener("mousedown", handleClickOutside) } return () => { document.removeEventListener("mousedown", handleClickOutside) } }, [showContextMenu, setShowContextMenu]) const handleMentionSelect = useCallback( (type: ContextMenuOptionType, value?: string) => { if (type === ContextMenuOptionType.NoResults) { return } if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) { if (!value) { setSelectedType(type) setSearchQuery("") setSelectedMenuIndex(0) return } } setShowContextMenu(false) setSelectedType(null) if (textAreaRef.current) { let insertValue = value || "" if (type === ContextMenuOptionType.URL) { insertValue = value || "" } else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) { insertValue = value || "" } else if (type === ContextMenuOptionType.Problems) { insertValue = "problems" } const { newValue, mentionIndex } = insertMention( textAreaRef.current.value, cursorPosition, insertValue ) setInputValue(newValue) const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1 setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) // textAreaRef.current.focus() // scroll to cursor setTimeout(() => { if (textAreaRef.current) { textAreaRef.current.blur() textAreaRef.current.focus() } }, 0) } }, [setInputValue, cursorPosition] ) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (showContextMenu) { if (event.key === "Escape") { // event.preventDefault() setSelectedType(null) setSelectedMenuIndex(3) // File by default return } if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault() setSelectedMenuIndex((prevIndex) => { const direction = event.key === "ArrowUp" ? -1 : 1 const options = getContextMenuOptions(searchQuery, selectedType, queryItems) const optionsLength = options.length if (optionsLength === 0) return prevIndex // Find selectable options (non-URL types) const selectableOptions = options.filter( (option) => option.type !== ContextMenuOptionType.URL && option.type !== ContextMenuOptionType.NoResults ) if (selectableOptions.length === 0) return -1 // No selectable options // Find the index of the next selectable option const currentSelectableIndex = selectableOptions.findIndex( (option) => option === options[prevIndex] ) const newSelectableIndex = (currentSelectableIndex + direction + selectableOptions.length) % selectableOptions.length // Find the index of the selected option in the original options array return options.findIndex((option) => option === selectableOptions[newSelectableIndex]) }) return } if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) { event.preventDefault() const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[ selectedMenuIndex ] if ( selectedOption && selectedOption.type !== ContextMenuOptionType.URL && selectedOption.type !== ContextMenuOptionType.NoResults ) { handleMentionSelect(selectedOption.type, selectedOption.value) } return } } const isComposing = event.nativeEvent?.isComposing ?? false if (event.key === "Enter" && !event.shiftKey && !isComposing) { event.preventDefault() onSend() } if (event.key === "Backspace" && !isComposing) { const charBeforeCursor = inputValue[cursorPosition - 1] const charAfterCursor = inputValue[cursorPosition + 1] const charBeforeIsWhitespace = charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n" const charAfterIsWhitespace = charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n" // checks if char before cusor is whitespace after a mention if ( charBeforeIsWhitespace && inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string ) { const newCursorPosition = cursorPosition - 1 // if mention is followed by another word, then instead of deleting the space separating them we just move the cursor to the end of the mention if (!charAfterIsWhitespace) { event.preventDefault() textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition) setCursorPosition(newCursorPosition) } setCursorPosition(newCursorPosition) setJustDeletedSpaceAfterMention(true) } else if (justDeletedSpaceAfterMention) { const { newText, newPosition } = removeMention(inputValue, cursorPosition) if (newText !== inputValue) { event.preventDefault() setInputValue(newText) setIntendedCursorPosition(newPosition) // Store the new cursor position in state } setJustDeletedSpaceAfterMention(false) setShowContextMenu(false) } else { setJustDeletedSpaceAfterMention(false) } } }, [ onSend, showContextMenu, searchQuery, selectedMenuIndex, handleMentionSelect, selectedType, inputValue, cursorPosition, setInputValue, justDeletedSpaceAfterMention, queryItems, ] ) useLayoutEffect(() => { if (intendedCursorPosition !== null && textAreaRef.current) { textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition) setIntendedCursorPosition(null) // Reset the state } }, [inputValue, intendedCursorPosition]) const handleInputChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value const newCursorPosition = e.target.selectionStart setInputValue(newValue) setCursorPosition(newCursorPosition) const showMenu = shouldShowContextMenu(newValue, newCursorPosition) setShowContextMenu(showMenu) if (showMenu) { const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1) const query = newValue.slice(lastAtIndex + 1, newCursorPosition) setSearchQuery(query) if (query.length > 0) { setSelectedMenuIndex(0) } else { setSelectedMenuIndex(3) // Set to "File" option by default } } else { setSearchQuery("") setSelectedMenuIndex(-1) } }, [setInputValue] ) useEffect(() => { if (!showContextMenu) { setSelectedType(null) } }, [showContextMenu]) const handleBlur = useCallback(() => { // Only hide the context menu if the user didn't click on it if (!isMouseDownOnMenu) { setShowContextMenu(false) } setIsTextAreaFocused(false) }, [isMouseDownOnMenu]) const handlePaste = useCallback( async (e: React.ClipboardEvent) => { const items = e.clipboardData.items const pastedText = e.clipboardData.getData("text") // Check if the pasted content is a URL, add space after so user can easily delete if they don't want it const urlRegex = /^\S+:\/\/\S+$/ if (urlRegex.test(pastedText.trim())) { e.preventDefault() const trimmedUrl = pastedText.trim() const newValue = inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition) setInputValue(newValue) const newCursorPosition = cursorPosition + trimmedUrl.length + 1 setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) setShowContextMenu(false) // Scroll to new cursor position // https://stackoverflow.com/questions/29899364/how-do-you-scroll-to-the-position-of-the-cursor-in-a-textarea/40951875#40951875 setTimeout(() => { if (textAreaRef.current) { textAreaRef.current.blur() textAreaRef.current.focus() } }, 0) // NOTE: callbacks dont utilize return function to cleanup, but it's fine since this timeout immediately executes and will be cleaned up by the browser (no chance component unmounts before it executes) return } const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg) const imageItems = Array.from(items).filter((item) => { const [type, subtype] = item.type.split("/") return type === "image" && acceptedTypes.includes(subtype) }) if (!shouldDisableImages && imageItems.length > 0) { e.preventDefault() const imagePromises = imageItems.map((item) => { return new Promise((resolve) => { const blob = item.getAsFile() if (!blob) { resolve(null) return } const reader = new FileReader() reader.onloadend = () => { if (reader.error) { console.error("Error reading file:", reader.error) resolve(null) } else { const result = reader.result resolve(typeof result === "string" ? result : null) } } reader.readAsDataURL(blob) }) }) const imageDataArray = await Promise.all(imagePromises) const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) //.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it if (dataUrls.length > 0) { setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) } else { console.warn("No valid images were processed") } } }, [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue] ) const handleThumbnailsHeightChange = useCallback((height: number) => { setThumbnailsHeight(height) }, []) useEffect(() => { if (selectedImages.length === 0) { setThumbnailsHeight(0) } }, [selectedImages]) const handleMenuMouseDown = useCallback(() => { setIsMouseDownOnMenu(true) }, []) const updateHighlights = useCallback(() => { if (!textAreaRef.current || !highlightLayerRef.current) return const text = textAreaRef.current.value highlightLayerRef.current.innerHTML = text .replace(/\n$/, "\n\n") .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c] || c)) .replace(mentionRegexGlobal, '$&') highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft }, []) useLayoutEffect(() => { updateHighlights() }, [inputValue, updateHighlights]) const updateCursorPosition = useCallback(() => { if (textAreaRef.current) { setCursorPosition(textAreaRef.current.selectionStart) } }, []) const handleKeyUp = useCallback( (e: React.KeyboardEvent) => { if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { updateCursorPosition() } }, [updateCursorPosition] ) return (
{showContextMenu && (
)} {!isTextAreaFocused && (
)}
{ if (typeof ref === "function") { ref(el) } else if (ref) { ref.current = el } textAreaRef.current = el }} value={inputValue} disabled={textAreaDisabled} onChange={(e) => { handleInputChange(e) updateHighlights() }} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onFocus={() => setIsTextAreaFocused(true)} onBlur={handleBlur} onPaste={handlePaste} onSelect={updateCursorPosition} onMouseUp={updateCursorPosition} onHeightChange={(height) => { if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { setTextAreaBaseHeight(height) } onHeightChange?.(height) }} placeholder={placeholderText} maxRows={10} autoFocus={true} style={{ width: "100%", boxSizing: "border-box", backgroundColor: "transparent", color: "var(--vscode-input-foreground)", //border: "1px solid var(--vscode-input-border)", borderRadius: 2, fontFamily: "var(--vscode-font-family)", fontSize: "var(--vscode-editor-font-size)", lineHeight: "var(--vscode-editor-line-height)", resize: "none", overflowX: "hidden", overflowY: "scroll", scrollbarWidth: "none", // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) // borderTop: "9px solid transparent", borderLeft: 0, borderRight: 0, borderTop: 0, borderBottom: `${thumbnailsHeight + 6}px solid transparent`, borderColor: "transparent", // borderRight: "54px solid transparent", // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", padding: "9px 49px 3px 9px", cursor: textAreaDisabled ? "not-allowed" : undefined, flex: 1, zIndex: 1, }} onScroll={() => updateHighlights()} /> {selectedImages.length > 0 && ( )}
{ if (!shouldDisableImages) { onSelectImages() } }} style={{ marginRight: 5.5, fontSize: 16.5, }} />
{ if (!textAreaDisabled) { onSend() } }} style={{ fontSize: 15 }}>
) } ) export default ChatTextArea