Roo-Code/webview-ui/src/components/chat/ChatTextArea.tsx
2025-04-06 14:31:14 -04:00

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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[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