import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import DynamicTextArea from "react-textarea-autosize" import { useExtensionState } from "../context/ExtensionStateContext" import { getContextMenuOptions, insertMention, removeMention, shouldShowContextMenu } from "../utils/mention-context" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" import Thumbnails from "./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 [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 { filePaths } = useExtensionState() const searchPaths = React.useMemo(() => { return [ { type: "problems", path: "problems" }, ...filePaths .map((file) => "/" + file) .map((path) => ({ type: path.endsWith("/") ? "folder" : "file", path: 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: string, value: string) => { if (value === "file" || value === "folder") { setSelectedType(type.toLowerCase()) setSearchQuery("") setSelectedMenuIndex(0) return } setShowContextMenu(false) setSelectedType(null) if (textAreaRef.current) { let insertValue = value if (type === "url") { // For URLs, we insert the value as is insertValue = value } else if (type === "file" || type === "folder") { // For files and folders, we insert the path insertValue = value } else if (type === "problems") { // For workspace problems, we insert @problems insertValue = "problems" } const newValue = insertMention(textAreaRef.current.value, cursorPosition, insertValue) setInputValue(newValue) const newCursorPosition = newValue.indexOf(" ", newValue.lastIndexOf("@")) + 1 setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) // Update intended cursor position textAreaRef.current.focus() // Remove the direct setSelectionRange call // textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition) } }, [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, searchPaths) const optionsLength = options.length if (optionsLength === 0) return prevIndex // Find selectable options (non-URL types) const selectableOptions = options.filter((option) => option.type !== "url") 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" && selectedMenuIndex !== -1) { event.preventDefault() const selectedOption = getContextMenuOptions(searchQuery, selectedType, searchPaths)[ selectedMenuIndex ] if (selectedOption && selectedOption.type !== "url") { 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" if ( charBeforeIsWhitespace && inputValue.slice(0, cursorPosition - 1).match(/@((?:\/|\w+:\/\/)[^\s]+|problems)$/) ) { const newCursorPosition = cursorPosition - 1 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, searchPaths, ] ) 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 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] ) 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 const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+|problems\b)/g highlightLayerRef.current.innerHTML = text .replace(/\n$/, "\n\n") .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c] || c)) .replace(mentionRegex, '$&') 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