mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-28 01:31:35 +00:00
1113 lines
34 KiB
TypeScript
1113 lines
34 KiB
TypeScript
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
import { useEvent } from "react-use"
|
|
import DynamicTextArea from "react-textarea-autosize"
|
|
|
|
import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
|
|
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
|
|
import { Mode, getAllModes } from "../../../../src/shared/modes"
|
|
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
|
|
|
import { vscode } from "@/utils/vscode"
|
|
import { useExtensionState } from "@/context/ExtensionStateContext"
|
|
import { useAppTranslation } from "@/i18n/TranslationContext"
|
|
import {
|
|
ContextMenuOptionType,
|
|
getContextMenuOptions,
|
|
insertMention,
|
|
removeMention,
|
|
shouldShowContextMenu,
|
|
SearchResult,
|
|
} from "@/utils/context-mentions"
|
|
import { convertToMentionPath } from "@/utils/path-mentions"
|
|
import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui"
|
|
|
|
import Thumbnails from "../common/Thumbnails"
|
|
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
|
import ContextMenu from "./ContextMenu"
|
|
import { VolumeX, Pin, Check } from "lucide-react"
|
|
import { IconButton } from "./IconButton"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface ChatTextAreaProps {
|
|
inputValue: string
|
|
setInputValue: (value: string) => void
|
|
textAreaDisabled: boolean
|
|
selectApiConfigDisabled: boolean
|
|
placeholderText: string
|
|
selectedImages: string[]
|
|
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
|
|
onSend: () => void
|
|
onSelectImages: () => void
|
|
shouldDisableImages: boolean
|
|
onHeightChange?: (height: number) => void
|
|
mode: Mode
|
|
setMode: (value: Mode) => void
|
|
modeShortcutText: string
|
|
}
|
|
|
|
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|
(
|
|
{
|
|
inputValue,
|
|
setInputValue,
|
|
textAreaDisabled,
|
|
selectApiConfigDisabled,
|
|
placeholderText,
|
|
selectedImages,
|
|
setSelectedImages,
|
|
onSend,
|
|
onSelectImages,
|
|
shouldDisableImages,
|
|
onHeightChange,
|
|
mode,
|
|
setMode,
|
|
modeShortcutText,
|
|
},
|
|
ref,
|
|
) => {
|
|
const { t } = useAppTranslation()
|
|
const {
|
|
filePaths,
|
|
openedTabs,
|
|
currentApiConfigName,
|
|
listApiConfigMeta,
|
|
customModes,
|
|
cwd,
|
|
pinnedApiConfigs,
|
|
togglePinnedApiConfig,
|
|
} = useExtensionState()
|
|
|
|
// Find the ID and display text for the currently selected API configuration
|
|
const { currentConfigId, displayName } = useMemo(() => {
|
|
const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName)
|
|
return {
|
|
currentConfigId: currentConfig?.id || "",
|
|
displayName: currentApiConfigName || "", // Use the name directly for display
|
|
}
|
|
}, [listApiConfigMeta, currentApiConfigName])
|
|
|
|
const [gitCommits, setGitCommits] = useState<any[]>([])
|
|
const [showDropdown, setShowDropdown] = useState(false)
|
|
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
|
|
const [searchLoading, setSearchLoading] = useState(false)
|
|
const [searchRequestId, setSearchRequestId] = useState<string>("")
|
|
|
|
// Close dropdown when clicking outside.
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (showDropdown) {
|
|
setShowDropdown(false)
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}, [showDropdown])
|
|
|
|
// Handle enhanced prompt response and search results.
|
|
useEffect(() => {
|
|
const messageHandler = (event: MessageEvent) => {
|
|
const message = event.data
|
|
|
|
if (message.type === "enhancedPrompt") {
|
|
if (message.text) {
|
|
setInputValue(message.text)
|
|
}
|
|
|
|
setIsEnhancingPrompt(false)
|
|
} else if (message.type === "commitSearchResults") {
|
|
const commits = message.commits.map((commit: any) => ({
|
|
type: ContextMenuOptionType.Git,
|
|
value: commit.hash,
|
|
label: commit.subject,
|
|
description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
|
|
icon: "$(git-commit)",
|
|
}))
|
|
|
|
setGitCommits(commits)
|
|
} else if (message.type === "fileSearchResults") {
|
|
setSearchLoading(false)
|
|
if (message.requestId === searchRequestId) {
|
|
setFileSearchResults(message.results || [])
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener("message", messageHandler)
|
|
return () => window.removeEventListener("message", messageHandler)
|
|
}, [setInputValue, searchRequestId])
|
|
|
|
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
|
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
|
const [showContextMenu, setShowContextMenu] = useState(false)
|
|
const [cursorPosition, setCursorPosition] = useState(0)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
|
|
const highlightLayerRef = useRef<HTMLDivElement>(null)
|
|
const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
|
|
const [selectedType, setSelectedType] = useState<ContextMenuOptionType | null>(null)
|
|
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
|
|
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
|
|
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
|
|
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
|
|
const [isFocused, setIsFocused] = useState(false)
|
|
|
|
// Fetch git commits when Git is selected or when typing a hash.
|
|
useEffect(() => {
|
|
if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) {
|
|
const message: WebviewMessage = {
|
|
type: "searchCommits",
|
|
query: searchQuery || "",
|
|
} as const
|
|
vscode.postMessage(message)
|
|
}
|
|
}, [selectedType, searchQuery])
|
|
|
|
const handleEnhancePrompt = useCallback(() => {
|
|
if (!textAreaDisabled) {
|
|
const trimmedInput = inputValue.trim()
|
|
if (trimmedInput) {
|
|
setIsEnhancingPrompt(true)
|
|
const message = {
|
|
type: "enhancePrompt" as const,
|
|
text: trimmedInput,
|
|
}
|
|
vscode.postMessage(message)
|
|
} else {
|
|
const promptDescription = t("chat:enhancePromptDescription")
|
|
setInputValue(promptDescription)
|
|
}
|
|
}
|
|
}, [inputValue, textAreaDisabled, setInputValue, t])
|
|
|
|
const queryItems = useMemo(() => {
|
|
return [
|
|
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
|
{ type: ContextMenuOptionType.Terminal, value: "terminal" },
|
|
...gitCommits,
|
|
...openedTabs
|
|
.filter((tab) => tab.path)
|
|
.map((tab) => ({
|
|
type: ContextMenuOptionType.OpenedFile,
|
|
value: "/" + tab.path,
|
|
})),
|
|
...filePaths
|
|
.map((file) => "/" + file)
|
|
.filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs
|
|
.map((path) => ({
|
|
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
|
|
value: path,
|
|
})),
|
|
]
|
|
}, [filePaths, gitCommits, openedTabs])
|
|
|
|
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.Mode && value) {
|
|
// Handle mode selection.
|
|
setMode(value)
|
|
setInputValue("")
|
|
setShowContextMenu(false)
|
|
vscode.postMessage({ type: "mode", text: value })
|
|
return
|
|
}
|
|
|
|
if (
|
|
type === ContextMenuOptionType.File ||
|
|
type === ContextMenuOptionType.Folder ||
|
|
type === ContextMenuOptionType.Git
|
|
) {
|
|
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"
|
|
} else if (type === ContextMenuOptionType.Terminal) {
|
|
insertValue = "terminal"
|
|
} else if (type === ContextMenuOptionType.Git) {
|
|
insertValue = value || ""
|
|
}
|
|
|
|
const { newValue, mentionIndex } = insertMention(
|
|
textAreaRef.current.value,
|
|
cursorPosition,
|
|
insertValue,
|
|
)
|
|
|
|
setInputValue(newValue)
|
|
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
|
|
setCursorPosition(newCursorPosition)
|
|
setIntendedCursorPosition(newCursorPosition)
|
|
|
|
// Scroll to cursor.
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.blur()
|
|
textAreaRef.current.focus()
|
|
}
|
|
}, 0)
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[setInputValue, cursorPosition],
|
|
)
|
|
|
|
const handleKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (showContextMenu) {
|
|
if (event.key === "Escape") {
|
|
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,
|
|
fileSearchResults,
|
|
getAllModes(customModes),
|
|
)
|
|
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,
|
|
fileSearchResults,
|
|
getAllModes(customModes),
|
|
)[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,
|
|
customModes,
|
|
fileSearchResults,
|
|
],
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
if (intendedCursorPosition !== null && textAreaRef.current) {
|
|
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
|
|
setIntendedCursorPosition(null) // Reset the state.
|
|
}
|
|
}, [inputValue, intendedCursorPosition])
|
|
// Ref to store the search timeout
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
const handleInputChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value
|
|
const newCursorPosition = e.target.selectionStart
|
|
setInputValue(newValue)
|
|
setCursorPosition(newCursorPosition)
|
|
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
|
|
|
|
setShowContextMenu(showMenu)
|
|
if (showMenu) {
|
|
if (newValue.startsWith("/")) {
|
|
// Handle slash command
|
|
const query = newValue
|
|
setSearchQuery(query)
|
|
setSelectedMenuIndex(0)
|
|
} else {
|
|
// Existing @ mention handling
|
|
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
|
|
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
|
|
setSearchQuery(query)
|
|
|
|
// Send file search request if query is not empty
|
|
if (query.length > 0) {
|
|
setSelectedMenuIndex(0)
|
|
// Don't clear results until we have new ones
|
|
// This prevents flickering
|
|
|
|
// Clear any existing timeout
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current)
|
|
}
|
|
|
|
// Set a timeout to debounce the search requests
|
|
searchTimeoutRef.current = setTimeout(() => {
|
|
// Generate a request ID for this search
|
|
const reqId = Math.random().toString(36).substring(2, 9)
|
|
setSearchRequestId(reqId)
|
|
setSearchLoading(true)
|
|
|
|
// Send message to extension to search files
|
|
vscode.postMessage({
|
|
type: "searchFiles",
|
|
query: query,
|
|
requestId: reqId,
|
|
})
|
|
}, 200) // 200ms debounce
|
|
} else {
|
|
setSelectedMenuIndex(3) // Set to "File" option by default
|
|
}
|
|
}
|
|
} else {
|
|
setSearchQuery("")
|
|
setSelectedMenuIndex(-1)
|
|
setFileSearchResults([]) // Clear file search results
|
|
}
|
|
},
|
|
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading],
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
setIsFocused(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.
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.blur()
|
|
textAreaRef.current.focus()
|
|
}
|
|
}, 0)
|
|
|
|
return
|
|
}
|
|
|
|
const acceptedTypes = ["png", "jpeg", "webp"]
|
|
|
|
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<string | null>((resolve) => {
|
|
const blob = item.getAsFile()
|
|
if (!blob) {
|
|
resolve(null)
|
|
return
|
|
}
|
|
const reader = new FileReader()
|
|
reader.onloadend = () => {
|
|
if (reader.error) {
|
|
console.error(t("chat:errorReadingFile"), 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)
|
|
if (dataUrls.length > 0) {
|
|
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
|
} else {
|
|
console.warn(t("chat:noValidImages"))
|
|
}
|
|
}
|
|
},
|
|
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
|
|
)
|
|
|
|
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, '<mark class="mention-context-textarea-highlight">$&</mark>')
|
|
|
|
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<HTMLTextAreaElement>) => {
|
|
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
|
|
updateCursorPosition()
|
|
}
|
|
},
|
|
[updateCursorPosition],
|
|
)
|
|
|
|
const handleDrop = useCallback(
|
|
async (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault()
|
|
setIsDraggingOver(false)
|
|
|
|
const text = e.dataTransfer.getData("text")
|
|
if (text) {
|
|
// Split text on newlines to handle multiple files
|
|
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
|
|
|
|
if (lines.length > 0) {
|
|
// Process each line as a separate file path
|
|
let newValue = inputValue.slice(0, cursorPosition)
|
|
let totalLength = 0
|
|
|
|
// Using a standard for loop instead of forEach for potential performance gains.
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
// Convert each path to a mention-friendly format
|
|
const mentionText = convertToMentionPath(line, cwd)
|
|
newValue += mentionText
|
|
totalLength += mentionText.length
|
|
|
|
// Add space after each mention except the last one
|
|
if (i < lines.length - 1) {
|
|
newValue += " "
|
|
totalLength += 1
|
|
}
|
|
}
|
|
|
|
// Add space after the last mention and append the rest of the input
|
|
newValue += " " + inputValue.slice(cursorPosition)
|
|
totalLength += 1
|
|
|
|
setInputValue(newValue)
|
|
const newCursorPosition = cursorPosition + totalLength
|
|
setCursorPosition(newCursorPosition)
|
|
setIntendedCursorPosition(newCursorPosition)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const files = Array.from(e.dataTransfer.files)
|
|
if (!textAreaDisabled && files.length > 0) {
|
|
const acceptedTypes = ["png", "jpeg", "webp"]
|
|
const imageFiles = files.filter((file) => {
|
|
const [type, subtype] = file.type.split("/")
|
|
return type === "image" && acceptedTypes.includes(subtype)
|
|
})
|
|
|
|
if (!shouldDisableImages && imageFiles.length > 0) {
|
|
const imagePromises = imageFiles.map((file) => {
|
|
return new Promise<string | null>((resolve) => {
|
|
const reader = new FileReader()
|
|
reader.onloadend = () => {
|
|
if (reader.error) {
|
|
console.error(t("chat:errorReadingFile"), reader.error)
|
|
resolve(null)
|
|
} else {
|
|
const result = reader.result
|
|
resolve(typeof result === "string" ? result : null)
|
|
}
|
|
}
|
|
reader.readAsDataURL(file)
|
|
})
|
|
})
|
|
const imageDataArray = await Promise.all(imagePromises)
|
|
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
|
if (dataUrls.length > 0) {
|
|
setSelectedImages((prevImages) =>
|
|
[...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE),
|
|
)
|
|
if (typeof vscode !== "undefined") {
|
|
vscode.postMessage({
|
|
type: "draggedImages",
|
|
dataUrls: dataUrls,
|
|
})
|
|
}
|
|
} else {
|
|
console.warn(t("chat:noValidImages"))
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[
|
|
cursorPosition,
|
|
cwd,
|
|
inputValue,
|
|
setInputValue,
|
|
setCursorPosition,
|
|
setIntendedCursorPosition,
|
|
textAreaDisabled,
|
|
shouldDisableImages,
|
|
setSelectedImages,
|
|
t,
|
|
],
|
|
)
|
|
|
|
const [isTtsPlaying, setIsTtsPlaying] = useState(false)
|
|
|
|
useEvent("message", (event: MessageEvent) => {
|
|
const message: ExtensionMessage = event.data
|
|
|
|
if (message.type === "ttsStart") {
|
|
setIsTtsPlaying(true)
|
|
} else if (message.type === "ttsStop") {
|
|
setIsTtsPlaying(false)
|
|
}
|
|
})
|
|
|
|
const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative",
|
|
"flex",
|
|
"flex-col",
|
|
"gap-2",
|
|
"bg-editor-background",
|
|
"m-2 mt-1",
|
|
"p-1.5",
|
|
"outline-none",
|
|
"border",
|
|
"border-none",
|
|
"w-[calc(100%-16px)]",
|
|
"ml-auto",
|
|
"mr-auto",
|
|
"box-border",
|
|
)}>
|
|
<div className="relative">
|
|
<div
|
|
className={cn("chat-text-area", "relative", "flex", "flex-col", "outline-none")}
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => {
|
|
//Only allowed to drop images/files on shift key pressed
|
|
if (!e.shiftKey) {
|
|
setIsDraggingOver(false)
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
setIsDraggingOver(true)
|
|
e.dataTransfer.dropEffect = "copy"
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.preventDefault()
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
if (
|
|
e.clientX <= rect.left ||
|
|
e.clientX >= rect.right ||
|
|
e.clientY <= rect.top ||
|
|
e.clientY >= rect.bottom
|
|
) {
|
|
setIsDraggingOver(false)
|
|
}
|
|
}}>
|
|
{showContextMenu && (
|
|
<div
|
|
ref={contextMenuContainerRef}
|
|
className={cn(
|
|
"absolute",
|
|
"bottom-full",
|
|
"left-0",
|
|
"right-0",
|
|
"z-[1000]",
|
|
"mb-2",
|
|
"filter",
|
|
"drop-shadow-md",
|
|
)}>
|
|
<ContextMenu
|
|
onSelect={handleMentionSelect}
|
|
searchQuery={searchQuery}
|
|
onMouseDown={handleMenuMouseDown}
|
|
selectedIndex={selectedMenuIndex}
|
|
setSelectedIndex={setSelectedMenuIndex}
|
|
selectedType={selectedType}
|
|
queryItems={queryItems}
|
|
modes={getAllModes(customModes)}
|
|
loading={searchLoading}
|
|
dynamicSearchResults={fileSearchResults}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"relative",
|
|
"flex-1",
|
|
"flex",
|
|
"flex-col-reverse",
|
|
"min-h-0",
|
|
"overflow-hidden",
|
|
"rounded",
|
|
)}>
|
|
<div
|
|
ref={highlightLayerRef}
|
|
className={cn(
|
|
"absolute",
|
|
"inset-0",
|
|
"pointer-events-none",
|
|
"whitespace-pre-wrap",
|
|
"break-words",
|
|
"text-transparent",
|
|
"overflow-hidden",
|
|
"font-vscode-font-family",
|
|
"text-vscode-editor-font-size",
|
|
"leading-vscode-editor-line-height",
|
|
"py-2",
|
|
"px-[9px]",
|
|
"z-[1000]",
|
|
)}
|
|
style={{
|
|
color: "transparent",
|
|
}}
|
|
/>
|
|
<DynamicTextArea
|
|
ref={(el) => {
|
|
if (typeof ref === "function") {
|
|
ref(el)
|
|
} else if (ref) {
|
|
ref.current = el
|
|
}
|
|
textAreaRef.current = el
|
|
}}
|
|
value={inputValue}
|
|
disabled={textAreaDisabled}
|
|
onChange={(e) => {
|
|
handleInputChange(e)
|
|
updateHighlights()
|
|
}}
|
|
onFocus={() => setIsFocused(true)}
|
|
onKeyDown={handleKeyDown}
|
|
onKeyUp={handleKeyUp}
|
|
onBlur={handleBlur}
|
|
onPaste={handlePaste}
|
|
onSelect={updateCursorPosition}
|
|
onMouseUp={updateCursorPosition}
|
|
onHeightChange={(height) => {
|
|
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
|
setTextAreaBaseHeight(height)
|
|
}
|
|
onHeightChange?.(height)
|
|
}}
|
|
placeholder={placeholderText}
|
|
minRows={3}
|
|
maxRows={15}
|
|
autoFocus={true}
|
|
className={cn(
|
|
"w-full",
|
|
"text-vscode-input-foreground",
|
|
"font-vscode-font-family",
|
|
"text-vscode-editor-font-size",
|
|
"leading-vscode-editor-line-height",
|
|
textAreaDisabled ? "cursor-not-allowed" : "cursor-text",
|
|
"py-1.5 px-2",
|
|
isFocused
|
|
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
|
|
: isDraggingOver
|
|
? "border-2 border-dashed border-vscode-focusBorder"
|
|
: "border border-transparent",
|
|
textAreaDisabled ? "opacity-50" : "opacity-100",
|
|
isDraggingOver
|
|
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
|
|
: "bg-vscode-input-background",
|
|
"transition-background-color duration-150 ease-in-out",
|
|
"will-change-background-color",
|
|
"h-[100px]",
|
|
"[@media(min-width:150px)]:min-h-[80px]",
|
|
"[@media(min-width:425px)]:min-h-[60px]",
|
|
"box-border",
|
|
"rounded",
|
|
"resize-none",
|
|
"overflow-x-hidden",
|
|
"overflow-y-auto",
|
|
"pr-2",
|
|
"flex-none flex-grow",
|
|
"z-[2]",
|
|
"scrollbar-none",
|
|
)}
|
|
onScroll={() => updateHighlights()}
|
|
/>
|
|
{isTtsPlaying && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
|
|
onClick={() => vscode.postMessage({ type: "stopTts" })}>
|
|
<VolumeX className="size-4" />
|
|
</Button>
|
|
)}
|
|
{!inputValue && (
|
|
<div
|
|
className={cn(
|
|
"absolute",
|
|
"left-2",
|
|
"flex",
|
|
"gap-2",
|
|
"text-xs",
|
|
"text-descriptionForeground",
|
|
"pointer-events-none",
|
|
"z-25",
|
|
"bottom-1.5",
|
|
"pr-2",
|
|
"transition-opacity",
|
|
"duration-200",
|
|
"ease-in-out",
|
|
textAreaDisabled ? "opacity-35" : "opacity-70",
|
|
)}>
|
|
{placeholderBottomText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedImages.length > 0 && (
|
|
<Thumbnails
|
|
images={selectedImages}
|
|
setImages={setSelectedImages}
|
|
style={{
|
|
left: "16px",
|
|
zIndex: 2,
|
|
marginBottom: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div className={cn("flex", "justify-between", "items-center", "mt-auto", "pt-0.5")}>
|
|
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
|
|
{/* Mode selector - fixed width */}
|
|
<div className="shrink-0">
|
|
<SelectDropdown
|
|
value={mode}
|
|
title={t("chat:selectMode")}
|
|
options={[
|
|
{
|
|
value: "shortcut",
|
|
label: modeShortcutText,
|
|
disabled: true,
|
|
type: DropdownOptionType.SHORTCUT,
|
|
},
|
|
...getAllModes(customModes).map((mode) => ({
|
|
value: mode.slug,
|
|
label: mode.name,
|
|
type: DropdownOptionType.ITEM,
|
|
})),
|
|
{
|
|
value: "sep-1",
|
|
label: t("chat:separator"),
|
|
type: DropdownOptionType.SEPARATOR,
|
|
},
|
|
{
|
|
value: "promptsButtonClicked",
|
|
label: t("chat:edit"),
|
|
type: DropdownOptionType.ACTION,
|
|
},
|
|
]}
|
|
onChange={(value) => {
|
|
setMode(value as Mode)
|
|
vscode.postMessage({ type: "mode", text: value })
|
|
}}
|
|
shortcutText={modeShortcutText}
|
|
triggerClassName="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* API configuration selector - flexible width */}
|
|
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
|
|
<SelectDropdown
|
|
value={currentConfigId}
|
|
disabled={selectApiConfigDisabled}
|
|
title={t("chat:selectApiConfig")}
|
|
placeholder={displayName} // Always show the current name
|
|
options={[
|
|
// Pinned items first
|
|
...(listApiConfigMeta || [])
|
|
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
|
|
.map((config) => ({
|
|
value: config.id,
|
|
label: config.name,
|
|
name: config.name, // Keep name for comparison with currentApiConfigName
|
|
type: DropdownOptionType.ITEM,
|
|
pinned: true,
|
|
}))
|
|
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
// If we have pinned items and unpinned items, add a separator
|
|
...(pinnedApiConfigs &&
|
|
Object.keys(pinnedApiConfigs).length > 0 &&
|
|
(listApiConfigMeta || []).some((config) => !pinnedApiConfigs[config.id])
|
|
? [
|
|
{
|
|
value: "sep-pinned",
|
|
label: t("chat:separator"),
|
|
type: DropdownOptionType.SEPARATOR,
|
|
},
|
|
]
|
|
: []),
|
|
// Unpinned items sorted alphabetically
|
|
...(listApiConfigMeta || [])
|
|
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
|
|
.map((config) => ({
|
|
value: config.id,
|
|
label: config.name,
|
|
name: config.name, // Keep name for comparison with currentApiConfigName
|
|
type: DropdownOptionType.ITEM,
|
|
pinned: false,
|
|
}))
|
|
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
{
|
|
value: "sep-2",
|
|
label: t("chat:separator"),
|
|
type: DropdownOptionType.SEPARATOR,
|
|
},
|
|
{
|
|
value: "settingsButtonClicked",
|
|
label: t("chat:edit"),
|
|
type: DropdownOptionType.ACTION,
|
|
},
|
|
]}
|
|
onChange={(value) => {
|
|
if (value === "settingsButtonClicked") {
|
|
vscode.postMessage({
|
|
type: "loadApiConfiguration",
|
|
text: value,
|
|
values: { section: "providers" },
|
|
})
|
|
} else {
|
|
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
|
|
}
|
|
}}
|
|
contentClassName="max-h-[300px] overflow-y-auto"
|
|
triggerClassName="w-full text-ellipsis overflow-hidden"
|
|
itemClassName="group"
|
|
renderItem={({ type, value, label, pinned }) => {
|
|
if (type !== DropdownOptionType.ITEM) {
|
|
return label
|
|
}
|
|
|
|
const config = listApiConfigMeta?.find((c) => c.id === value)
|
|
const isCurrentConfig = config?.name === currentApiConfigName
|
|
|
|
return (
|
|
<div className="flex justify-between gap-2 w-full h-5">
|
|
<div className={cn({ "font-medium": isCurrentConfig })}>{label}</div>
|
|
<div className="flex justify-end w-10">
|
|
<div
|
|
className={cn("size-5 p-1", {
|
|
"block group-hover:hidden": !pinned,
|
|
hidden: !isCurrentConfig,
|
|
})}>
|
|
<Check className="size-3" />
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title={pinned ? t("chat:unpin") : t("chat:pin")}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
togglePinnedApiConfig(value)
|
|
vscode.postMessage({ type: "toggleApiConfigPin", text: value })
|
|
}}
|
|
className={cn("size-5", {
|
|
"hidden group-hover:flex": !pinned,
|
|
"bg-accent": pinned,
|
|
})}>
|
|
<Pin className="size-3 p-0.5 opacity-50" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right side - action buttons */}
|
|
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
|
|
<IconButton
|
|
iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
|
|
title={t("chat:enhancePrompt")}
|
|
disabled={textAreaDisabled}
|
|
isLoading={isEnhancingPrompt}
|
|
onClick={handleEnhancePrompt}
|
|
/>
|
|
<IconButton
|
|
iconClass="codicon-device-camera"
|
|
title={t("chat:addImages")}
|
|
disabled={shouldDisableImages}
|
|
onClick={onSelectImages}
|
|
/>
|
|
<IconButton
|
|
iconClass="codicon-send"
|
|
title={t("chat:sendMessage")}
|
|
disabled={textAreaDisabled}
|
|
onClick={onSend}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
|
|
export default ChatTextArea
|