mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
1744 lines
58 KiB
TypeScript
1744 lines
58 KiB
TypeScript
import { PulsingBorder } from "@paper-design/shaders-react"
|
|
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
|
|
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
|
import { FileSearchRequest, FileSearchType, RefreshedSkills, RelativePathsRequest, SkillInfo } from "@shared/proto/cline/file"
|
|
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
|
|
import { type SlashCommand } from "@shared/slashCommands"
|
|
import { Mode } from "@shared/storage/types"
|
|
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
|
import { AtSignIcon, PlusIcon } from "lucide-react"
|
|
import type React from "react"
|
|
import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
import DynamicTextArea from "react-textarea-autosize"
|
|
import styled from "styled-components"
|
|
import ContextMenu from "@/components/chat/ContextMenu"
|
|
import { CHAT_CONSTANTS } from "@/components/chat/chat-view/constants"
|
|
import SlashCommandMenu from "@/components/chat/SlashCommandMenu"
|
|
import Thumbnails from "@/components/common/Thumbnails"
|
|
import { getModeSpecificFields, normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { useClineAuth } from "@/context/ClineAuthContext"
|
|
import { useExtensionState } from "@/context/ExtensionStateContext"
|
|
import { usePlatform } from "@/context/PlatformContext"
|
|
import { cn } from "@/lib/utils"
|
|
import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
|
|
import {
|
|
ContextMenuOptionType,
|
|
getContextMenuOptionIndex,
|
|
getContextMenuOptions,
|
|
insertMention,
|
|
insertMentionDirectly,
|
|
removeMention,
|
|
type SearchResult,
|
|
shouldShowContextMenu,
|
|
} from "@/utils/context-mentions"
|
|
import { useMetaKeyDetection, useShortcut } from "@/utils/hooks"
|
|
import { isSafari } from "@/utils/platformUtils"
|
|
import {
|
|
getMatchingSlashCommands,
|
|
insertSlashCommand,
|
|
removeSlashCommand,
|
|
shouldShowSlashCommandsMenu,
|
|
slashCommandDeleteRegex,
|
|
slashCommandRegexGlobal,
|
|
validateSlashCommand,
|
|
} from "@/utils/slash-commands"
|
|
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
|
|
import ServersToggleModal from "./ServersToggleModal"
|
|
import VoiceRecorder from "./VoiceRecorder"
|
|
|
|
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
|
|
|
|
const getImageDimensions = (dataUrl: string): Promise<{ width: number; height: number }> => {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
if (img.naturalWidth > 7500 || img.naturalHeight > 7500) {
|
|
reject(new Error("Image dimensions exceed maximum allowed size of 7500px."))
|
|
} else {
|
|
resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
}
|
|
}
|
|
img.onerror = (err) => {
|
|
console.error("Failed to load image for dimension check:", err)
|
|
reject(new Error("Failed to load image to check dimensions."))
|
|
}
|
|
img.src = dataUrl
|
|
})
|
|
}
|
|
|
|
// Set to "File" option by default
|
|
const DEFAULT_CONTEXT_MENU_OPTION = getContextMenuOptionIndex(ContextMenuOptionType.File)
|
|
|
|
interface ChatTextAreaProps {
|
|
inputValue: string
|
|
activeQuote: string | null
|
|
setInputValue: (value: string) => void
|
|
sendingDisabled: boolean
|
|
placeholderText: string
|
|
selectedFiles: string[]
|
|
selectedImages: string[]
|
|
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
|
|
setSelectedFiles: React.Dispatch<React.SetStateAction<string[]>>
|
|
onSend: () => void
|
|
onSelectFilesAndImages: () => void
|
|
shouldDisableFilesAndImages: boolean
|
|
onHeightChange?: (height: number) => void
|
|
onFocusChange?: (isFocused: boolean) => void
|
|
}
|
|
|
|
interface GitCommit {
|
|
type: ContextMenuOptionType.Git
|
|
value: string
|
|
label: string
|
|
description: string
|
|
}
|
|
|
|
const PLAN_MODE_COLOR = "var(--vscode-activityWarningBadge-background)"
|
|
const ACT_MODE_COLOR = "var(--vscode-focusBorder)"
|
|
|
|
const SwitchContainer = styled.div<{ disabled: boolean }>`
|
|
display: flex;
|
|
align-items: center;
|
|
background-color: transparent;
|
|
border: 1px solid var(--vscode-input-border);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
|
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
|
|
transform: scale(1);
|
|
transform-origin: right center;
|
|
margin-left: 0;
|
|
user-select: none; // Prevent text selection
|
|
`
|
|
|
|
const Slider = styled.div.withConfig({
|
|
shouldForwardProp: (prop) => !["isAct", "isPlan"].includes(prop),
|
|
})<{ isAct: boolean; isPlan?: boolean }>`
|
|
position: absolute;
|
|
height: 100%;
|
|
width: 50%;
|
|
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : ACT_MODE_COLOR)};
|
|
transition: transform 0.2s ease;
|
|
transform: translateX(${(props) => (props.isAct ? "100%" : "0%")});
|
|
`
|
|
|
|
const ButtonGroup = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
`
|
|
|
|
const ButtonContainer = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
font-size: 10px;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
width: 100%;
|
|
`
|
|
|
|
const ModelContainer = styled.div`
|
|
position: relative;
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
`
|
|
|
|
const ModelButtonWrapper = styled.div`
|
|
display: inline-flex; // Make it shrink to content
|
|
min-width: 0; // Allow shrinking
|
|
max-width: 100%; // Don't overflow parent
|
|
`
|
|
|
|
const ModelDisplayButton = styled.a<{ isActive?: boolean; disabled?: boolean }>`
|
|
padding: 0px 0px;
|
|
height: 20px;
|
|
width: 100%;
|
|
min-width: 0;
|
|
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
|
text-decoration: ${(props) => (props.isActive ? "underline" : "none")};
|
|
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 10px;
|
|
outline: none;
|
|
user-select: none;
|
|
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
|
|
pointer-events: ${(props) => (props.disabled ? "none" : "auto")};
|
|
|
|
&:hover,
|
|
&:focus {
|
|
color: ${(props) => (props.disabled ? "var(--vscode-descriptionForeground)" : "var(--vscode-foreground)")};
|
|
text-decoration: ${(props) => (props.disabled ? "none" : "underline")};
|
|
outline: none;
|
|
}
|
|
|
|
&:active {
|
|
color: ${(props) => (props.disabled ? "var(--vscode-descriptionForeground)" : "var(--vscode-foreground)")};
|
|
text-decoration: ${(props) => (props.disabled ? "none" : "underline")};
|
|
outline: none;
|
|
}
|
|
|
|
&:focus-visible {
|
|
outline: none;
|
|
}
|
|
`
|
|
|
|
const ModelButtonContent = styled.div`
|
|
width: 100%;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
`
|
|
|
|
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|
(
|
|
{
|
|
inputValue,
|
|
setInputValue,
|
|
sendingDisabled,
|
|
placeholderText,
|
|
selectedFiles,
|
|
selectedImages,
|
|
setSelectedImages,
|
|
setSelectedFiles,
|
|
onSend,
|
|
onSelectFilesAndImages,
|
|
shouldDisableFilesAndImages,
|
|
onHeightChange,
|
|
onFocusChange,
|
|
},
|
|
ref,
|
|
) => {
|
|
const {
|
|
mode,
|
|
apiConfiguration,
|
|
openRouterModels,
|
|
platform,
|
|
globalSkillsToggles,
|
|
localSkillsToggles,
|
|
localWorkflowToggles,
|
|
globalWorkflowToggles,
|
|
remoteWorkflowToggles,
|
|
remoteConfigSettings,
|
|
navigateToSettingsModelPicker,
|
|
dictationSettings,
|
|
mcpServers,
|
|
} = useExtensionState()
|
|
const { clineUser } = useClineAuth()
|
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
|
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
|
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
|
|
const [isVoiceRecording, setIsVoiceRecording] = useState(false)
|
|
const [showSlashCommandsMenu, setShowSlashCommandsMenu] = useState(false)
|
|
const [selectedSlashCommandsIndex, setSelectedSlashCommandsIndex] = useState(0)
|
|
const [slashCommandsQuery, setSlashCommandsQuery] = useState("")
|
|
const slashCommandsMenuContainerRef = useRef<HTMLDivElement>(null)
|
|
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
|
|
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
|
|
|
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
|
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 [justDeletedSpaceAfterSlashCommand, setJustDeletedSpaceAfterSlashCommand] = useState(false)
|
|
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
|
|
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
const [shownTooltipMode, setShownTooltipMode] = useState<Mode | null>(null)
|
|
const [pendingInsertions, setPendingInsertions] = useState<string[]>([])
|
|
const _shiftHoldTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
const [showUnsupportedFileError, setShowUnsupportedFileError] = useState(false)
|
|
const unsupportedFileTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
const [showDimensionError, setShowDimensionError] = useState(false)
|
|
const dimensionErrorTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
|
|
const [searchLoading, setSearchLoading] = useState(false)
|
|
const [, metaKeyChar] = useMetaKeyDetection(platform)
|
|
|
|
// Fetch git commits when Git is selected or when typing a hash
|
|
useEffect(() => {
|
|
if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) {
|
|
FileServiceClient.searchCommits(StringRequest.create({ value: searchQuery || "" }))
|
|
.then((response) => {
|
|
if (response.commits) {
|
|
const commits: GitCommit[] = response.commits.map(
|
|
(commit: { hash: string; shortHash: string; subject: string; author: string; date: string }) => ({
|
|
type: ContextMenuOptionType.Git,
|
|
value: commit.hash,
|
|
label: commit.subject,
|
|
description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
|
|
}),
|
|
)
|
|
setGitCommits(commits)
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error searching commits:", error)
|
|
})
|
|
}
|
|
}, [selectedType, searchQuery])
|
|
|
|
const queryItems = useMemo(() => {
|
|
return [
|
|
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
|
{ type: ContextMenuOptionType.Terminal, value: "terminal" },
|
|
...gitCommits,
|
|
]
|
|
}, [gitCommits])
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
const handleClickOutsideSlashMenu = (event: MouseEvent) => {
|
|
if (
|
|
slashCommandsMenuContainerRef.current &&
|
|
!slashCommandsMenuContainerRef.current.contains(event.target as Node)
|
|
) {
|
|
setShowSlashCommandsMenu(false)
|
|
}
|
|
}
|
|
|
|
if (showSlashCommandsMenu) {
|
|
document.addEventListener("mousedown", handleClickOutsideSlashMenu)
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutsideSlashMenu)
|
|
}
|
|
}, [showSlashCommandsMenu])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
FileServiceClient.refreshSkills(EmptyRequest.create())
|
|
.then((response: RefreshedSkills) => {
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
setGlobalSkills(response.globalSkills || [])
|
|
setLocalSkills(response.localSkills || [])
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setGlobalSkills([])
|
|
setLocalSkills([])
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [globalSkillsToggles, localSkillsToggles])
|
|
|
|
const handleMentionSelect = useCallback(
|
|
(type: ContextMenuOptionType, value?: string) => {
|
|
if (type === ContextMenuOptionType.NoResults) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
type === ContextMenuOptionType.File ||
|
|
type === ContextMenuOptionType.Folder ||
|
|
type === ContextMenuOptionType.Git
|
|
) {
|
|
if (!value) {
|
|
setSelectedType(type)
|
|
setSearchQuery("")
|
|
setSelectedMenuIndex(0)
|
|
|
|
// Trigger search with the selected type
|
|
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
|
|
setSearchLoading(true)
|
|
|
|
// Map ContextMenuOptionType to FileSearchType enum
|
|
let searchType: FileSearchType | undefined
|
|
if (type === ContextMenuOptionType.File) {
|
|
searchType = FileSearchType.FILE
|
|
} else if (type === ContextMenuOptionType.Folder) {
|
|
searchType = FileSearchType.FOLDER
|
|
}
|
|
|
|
FileServiceClient.searchFiles(
|
|
FileSearchRequest.create({
|
|
query: "",
|
|
mentionsRequestId: "",
|
|
selectedType: searchType,
|
|
}),
|
|
)
|
|
.then((results) => {
|
|
setFileSearchResults((results.results || []) as SearchResult[])
|
|
setSearchLoading(false)
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error searching files:", error)
|
|
setFileSearchResults([])
|
|
setSearchLoading(false)
|
|
})
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
setShowContextMenu(false)
|
|
setSelectedType(null)
|
|
const queryLength = searchQuery.length
|
|
setSearchQuery("")
|
|
|
|
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,
|
|
queryLength,
|
|
)
|
|
|
|
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, searchQuery],
|
|
)
|
|
|
|
const handleSlashCommandsSelect = useCallback(
|
|
(command: SlashCommand) => {
|
|
setShowSlashCommandsMenu(false)
|
|
const queryLength = slashCommandsQuery.length
|
|
setSlashCommandsQuery("")
|
|
|
|
if (textAreaRef.current) {
|
|
const { newValue, commandIndex } = insertSlashCommand(
|
|
textAreaRef.current.value,
|
|
command.name,
|
|
queryLength,
|
|
cursorPosition,
|
|
)
|
|
const newCursorPosition = newValue.indexOf(" ", commandIndex + 1 + command.name.length) + 1
|
|
|
|
setInputValue(newValue)
|
|
setCursorPosition(newCursorPosition)
|
|
setIntendedCursorPosition(newCursorPosition)
|
|
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.blur()
|
|
textAreaRef.current.focus()
|
|
}
|
|
}, 0)
|
|
}
|
|
},
|
|
[setInputValue, slashCommandsQuery, cursorPosition],
|
|
)
|
|
const handleKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (showSlashCommandsMenu) {
|
|
if (event.key === "Escape") {
|
|
setShowSlashCommandsMenu(false)
|
|
setSlashCommandsQuery("")
|
|
return
|
|
}
|
|
|
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
event.preventDefault()
|
|
setSelectedSlashCommandsIndex((prevIndex) => {
|
|
const direction = event.key === "ArrowUp" ? -1 : 1
|
|
// Get commands with workflow toggles
|
|
const allCommands = getMatchingSlashCommands(
|
|
slashCommandsQuery,
|
|
localWorkflowToggles,
|
|
globalWorkflowToggles,
|
|
remoteWorkflowToggles,
|
|
remoteConfigSettings?.remoteGlobalWorkflows,
|
|
mcpServers,
|
|
globalSkills,
|
|
localSkills,
|
|
)
|
|
|
|
if (allCommands.length === 0) {
|
|
return prevIndex
|
|
}
|
|
|
|
// Calculate total command count
|
|
const totalCommandCount = allCommands.length
|
|
|
|
// Create wraparound navigation - moves from last item to first and vice versa
|
|
const newIndex = (prevIndex + direction + totalCommandCount) % totalCommandCount
|
|
return newIndex
|
|
})
|
|
return
|
|
}
|
|
|
|
if ((event.key === "Enter" || event.key === "Tab") && selectedSlashCommandsIndex !== -1) {
|
|
event.preventDefault()
|
|
const commands = getMatchingSlashCommands(
|
|
slashCommandsQuery,
|
|
localWorkflowToggles,
|
|
globalWorkflowToggles,
|
|
remoteWorkflowToggles,
|
|
remoteConfigSettings?.remoteGlobalWorkflows,
|
|
mcpServers,
|
|
globalSkills,
|
|
localSkills,
|
|
)
|
|
if (commands.length > 0) {
|
|
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if (showContextMenu) {
|
|
if (event.key === "Escape") {
|
|
setShowContextMenu(false)
|
|
setSelectedType(null)
|
|
setSelectedMenuIndex(DEFAULT_CONTEXT_MENU_OPTION)
|
|
setSearchQuery("")
|
|
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)
|
|
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.indexOf(options[prevIndex])
|
|
|
|
const newSelectableIndex =
|
|
(currentSelectableIndex + direction + selectableOptions.length) % selectableOptions.length
|
|
|
|
// Find the index of the selected option in the original options array
|
|
return options.indexOf(selectableOptions[newSelectableIndex])
|
|
})
|
|
return
|
|
}
|
|
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
|
|
event.preventDefault()
|
|
const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems, fileSearchResults)[
|
|
selectedMenuIndex
|
|
]
|
|
if (
|
|
selectedOption &&
|
|
selectedOption.type !== ContextMenuOptionType.URL &&
|
|
selectedOption.type !== ContextMenuOptionType.NoResults
|
|
) {
|
|
// Use label if it contains workspace prefix, otherwise use value
|
|
const mentionValue = selectedOption.label?.includes(":") ? selectedOption.label : selectedOption.value
|
|
handleMentionSelect(selectedOption.type, mentionValue)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Safari does not support InputEvent.isComposing (always false), so we need to fallback to keyCode === 229 for it
|
|
const isComposing = isSafari ? event.nativeEvent.keyCode === 229 : (event.nativeEvent?.isComposing ?? false)
|
|
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
|
event.preventDefault()
|
|
|
|
if (!sendingDisabled) {
|
|
setIsTextAreaFocused(false)
|
|
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"
|
|
|
|
// Check if we're right after a space that follows a mention or slash command
|
|
if (
|
|
charBeforeIsWhitespace &&
|
|
inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$"))
|
|
) {
|
|
// File mention handling
|
|
const newCursorPosition = cursorPosition - 1
|
|
if (!charAfterIsWhitespace) {
|
|
event.preventDefault()
|
|
textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition)
|
|
setCursorPosition(newCursorPosition)
|
|
}
|
|
setCursorPosition(newCursorPosition)
|
|
setJustDeletedSpaceAfterMention(true)
|
|
setJustDeletedSpaceAfterSlashCommand(false)
|
|
} else if (charBeforeIsWhitespace && inputValue.slice(0, cursorPosition - 1).match(slashCommandDeleteRegex)) {
|
|
// New slash command handling
|
|
const newCursorPosition = cursorPosition - 1
|
|
if (!charAfterIsWhitespace) {
|
|
event.preventDefault()
|
|
textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition)
|
|
setCursorPosition(newCursorPosition)
|
|
}
|
|
setCursorPosition(newCursorPosition)
|
|
setJustDeletedSpaceAfterSlashCommand(true)
|
|
setJustDeletedSpaceAfterMention(false)
|
|
}
|
|
// Handle the second backspace press for mentions or slash commands
|
|
else if (justDeletedSpaceAfterMention) {
|
|
const { newText, newPosition } = removeMention(inputValue, cursorPosition)
|
|
if (newText !== inputValue) {
|
|
event.preventDefault()
|
|
setInputValue(newText)
|
|
setIntendedCursorPosition(newPosition)
|
|
}
|
|
setJustDeletedSpaceAfterMention(false)
|
|
setShowContextMenu(false)
|
|
} else if (justDeletedSpaceAfterSlashCommand) {
|
|
// New slash command deletion
|
|
const { newText, newPosition } = removeSlashCommand(inputValue, cursorPosition)
|
|
if (newText !== inputValue) {
|
|
event.preventDefault()
|
|
setInputValue(newText)
|
|
setIntendedCursorPosition(newPosition)
|
|
}
|
|
setJustDeletedSpaceAfterSlashCommand(false)
|
|
setShowSlashCommandsMenu(false)
|
|
}
|
|
// Default case - reset flags if none of the above apply
|
|
else {
|
|
setJustDeletedSpaceAfterMention(false)
|
|
setJustDeletedSpaceAfterSlashCommand(false)
|
|
}
|
|
}
|
|
},
|
|
[
|
|
onSend,
|
|
showContextMenu,
|
|
searchQuery,
|
|
selectedMenuIndex,
|
|
handleMentionSelect,
|
|
selectedType,
|
|
inputValue,
|
|
cursorPosition,
|
|
setInputValue,
|
|
justDeletedSpaceAfterMention,
|
|
queryItems,
|
|
fileSearchResults,
|
|
showSlashCommandsMenu,
|
|
selectedSlashCommandsIndex,
|
|
slashCommandsQuery,
|
|
handleSlashCommandsSelect,
|
|
sendingDisabled,
|
|
globalSkills,
|
|
localSkills,
|
|
],
|
|
)
|
|
|
|
// Effect to set cursor position after state updates
|
|
useLayoutEffect(() => {
|
|
if (intendedCursorPosition !== null && textAreaRef.current) {
|
|
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
|
|
setIntendedCursorPosition(null) // Reset the state after applying
|
|
}
|
|
}, [inputValue, intendedCursorPosition])
|
|
|
|
useEffect(() => {
|
|
if (pendingInsertions.length === 0 || !textAreaRef.current) {
|
|
return
|
|
}
|
|
|
|
const path = pendingInsertions[0]
|
|
const currentTextArea = textAreaRef.current
|
|
const currentValue = currentTextArea.value
|
|
const currentCursorPos =
|
|
intendedCursorPosition ??
|
|
(currentTextArea.selectionStart >= 0 ? currentTextArea.selectionStart : currentValue.length)
|
|
|
|
const { newValue, mentionIndex } = insertMentionDirectly(currentValue, currentCursorPos, path)
|
|
|
|
setInputValue(newValue)
|
|
|
|
const newCursorPosition = mentionIndex + path.length + 2
|
|
setIntendedCursorPosition(newCursorPosition)
|
|
|
|
setPendingInsertions((prev) => prev.slice(1))
|
|
}, [pendingInsertions, setInputValue])
|
|
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
const currentSearchQueryRef = useRef<string>("")
|
|
|
|
const handleInputChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value
|
|
const newCursorPosition = e.target.selectionStart
|
|
setInputValue(newValue)
|
|
setCursorPosition(newCursorPosition)
|
|
let showMenu = shouldShowContextMenu(newValue, newCursorPosition)
|
|
const showSlashCommandsMenu = shouldShowSlashCommandsMenu(newValue, newCursorPosition)
|
|
|
|
// we do not allow both menus to be shown at the same time
|
|
// the slash commands menu has precedence bc its a narrower component
|
|
if (showSlashCommandsMenu) {
|
|
showMenu = false
|
|
}
|
|
|
|
setShowSlashCommandsMenu(showSlashCommandsMenu)
|
|
setShowContextMenu(showMenu)
|
|
|
|
if (showSlashCommandsMenu) {
|
|
// Find the slash nearest to cursor (before cursor position)
|
|
const beforeCursor = newValue.slice(0, newCursorPosition)
|
|
const slashIndex = beforeCursor.lastIndexOf("/")
|
|
const query = newValue.slice(slashIndex + 1, newCursorPosition)
|
|
setSlashCommandsQuery(query)
|
|
setSelectedSlashCommandsIndex(0)
|
|
} else {
|
|
setSlashCommandsQuery("")
|
|
setSelectedSlashCommandsIndex(0)
|
|
}
|
|
|
|
if (showMenu) {
|
|
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
|
|
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
|
|
setSearchQuery(query)
|
|
currentSearchQueryRef.current = query
|
|
|
|
if (query.length > 0) {
|
|
setSelectedMenuIndex(0)
|
|
|
|
// Clear any existing timeout
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current)
|
|
}
|
|
|
|
setSearchLoading(true)
|
|
|
|
const searchType =
|
|
selectedType === ContextMenuOptionType.File
|
|
? FileSearchType.FILE
|
|
: selectedType === ContextMenuOptionType.Folder
|
|
? FileSearchType.FOLDER
|
|
: undefined
|
|
|
|
// Parse workspace hint from query (e.g., "@frontend:/filename")
|
|
let workspaceHint: string | undefined
|
|
let searchQuery = query
|
|
const workspaceHintMatch = query.match(/^([\w-]+):\/(.*)$/)
|
|
if (workspaceHintMatch) {
|
|
workspaceHint = workspaceHintMatch[1]
|
|
searchQuery = workspaceHintMatch[2]
|
|
}
|
|
|
|
// Set a timeout to debounce the search requests
|
|
searchTimeoutRef.current = setTimeout(() => {
|
|
FileServiceClient.searchFiles(
|
|
FileSearchRequest.create({
|
|
query: searchQuery,
|
|
mentionsRequestId: query,
|
|
selectedType: searchType,
|
|
workspaceHint: workspaceHint,
|
|
}),
|
|
)
|
|
.then((results) => {
|
|
setFileSearchResults((results.results || []) as SearchResult[])
|
|
setSearchLoading(false)
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error searching files:", error)
|
|
setFileSearchResults([])
|
|
setSearchLoading(false)
|
|
})
|
|
}, 200) // 200ms debounce
|
|
} else {
|
|
setSelectedMenuIndex(DEFAULT_CONTEXT_MENU_OPTION)
|
|
}
|
|
} else {
|
|
setSearchQuery("")
|
|
setSelectedMenuIndex(-1)
|
|
setFileSearchResults([])
|
|
}
|
|
},
|
|
[setInputValue, setFileSearchResults, selectedType],
|
|
)
|
|
|
|
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)
|
|
setShowSlashCommandsMenu(false)
|
|
}
|
|
setIsTextAreaFocused(false)
|
|
onFocusChange?.(false) // Call prop on blur
|
|
}, [isMouseDownOnMenu, onFocusChange])
|
|
|
|
const showDimensionErrorMessage = useCallback(() => {
|
|
setShowDimensionError(true)
|
|
if (dimensionErrorTimerRef.current) {
|
|
clearTimeout(dimensionErrorTimerRef.current)
|
|
}
|
|
dimensionErrorTimerRef.current = setTimeout(() => {
|
|
setShowDimensionError(false)
|
|
dimensionErrorTimerRef.current = null
|
|
}, 3000)
|
|
}, [])
|
|
|
|
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 (!shouldDisableFilesAndImages && 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 = async () => {
|
|
if (reader.error) {
|
|
console.error("Error reading file:", reader.error)
|
|
resolve(null)
|
|
} else {
|
|
const result = reader.result
|
|
if (typeof result === "string") {
|
|
try {
|
|
await getImageDimensions(result)
|
|
resolve(result)
|
|
} catch (error) {
|
|
console.warn((error as Error).message)
|
|
showDimensionErrorMessage()
|
|
resolve(null)
|
|
}
|
|
} else {
|
|
resolve(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) {
|
|
const filesAndImagesLength = selectedImages.length + selectedFiles.length
|
|
const availableSlots = MAX_IMAGES_AND_FILES_PER_MESSAGE - filesAndImagesLength
|
|
|
|
if (availableSlots > 0) {
|
|
const imagesToAdd = Math.min(dataUrls.length, availableSlots)
|
|
setSelectedImages((prevImages) => [...prevImages, ...dataUrls.slice(0, imagesToAdd)])
|
|
}
|
|
} else {
|
|
console.warn("No valid images were processed")
|
|
}
|
|
}
|
|
},
|
|
[
|
|
shouldDisableFilesAndImages,
|
|
setSelectedImages,
|
|
selectedImages,
|
|
selectedFiles,
|
|
cursorPosition,
|
|
setInputValue,
|
|
inputValue,
|
|
showDimensionErrorMessage,
|
|
],
|
|
)
|
|
|
|
const handleThumbnailsHeightChange = useCallback((height: number) => {
|
|
setThumbnailsHeight(height)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (selectedImages.length === 0 && selectedFiles.length === 0) {
|
|
setThumbnailsHeight(0)
|
|
}
|
|
}, [selectedImages, selectedFiles])
|
|
|
|
const handleMenuMouseDown = useCallback(() => {
|
|
setIsMouseDownOnMenu(true)
|
|
}, [])
|
|
|
|
const updateHighlights = useCallback(() => {
|
|
if (!textAreaRef.current || !highlightLayerRef.current) {
|
|
return
|
|
}
|
|
|
|
let processedText = textAreaRef.current.value
|
|
|
|
processedText = processedText
|
|
.replace(/\n$/, "\n\n")
|
|
.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c)
|
|
// highlight @mentions
|
|
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
|
|
|
|
// Highlight only the FIRST valid /slash-command in the text
|
|
// Only one slash command is processed per message, so we only highlight the first one
|
|
slashCommandRegexGlobal.lastIndex = 0
|
|
let hasHighlightedSlashCommand = false
|
|
processedText = processedText.replace(slashCommandRegexGlobal, (match, prefix, command) => {
|
|
// Only highlight the first valid slash command
|
|
if (hasHighlightedSlashCommand) {
|
|
return match
|
|
}
|
|
|
|
// Extract just the command name (without the slash)
|
|
const commandName = command.substring(1)
|
|
const isValidCommand = validateSlashCommand(
|
|
commandName,
|
|
localWorkflowToggles,
|
|
globalWorkflowToggles,
|
|
remoteWorkflowToggles,
|
|
remoteConfigSettings?.remoteGlobalWorkflows,
|
|
mcpServers,
|
|
globalSkills,
|
|
localSkills,
|
|
)
|
|
|
|
if (isValidCommand) {
|
|
hasHighlightedSlashCommand = true
|
|
// Keep the prefix (whitespace or empty) and wrap the command in highlight
|
|
return `${prefix}<mark class="mention-context-textarea-highlight">${command}</mark>`
|
|
}
|
|
return match
|
|
})
|
|
|
|
highlightLayerRef.current.innerHTML = processedText
|
|
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
|
|
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
|
|
}, [
|
|
localWorkflowToggles,
|
|
globalWorkflowToggles,
|
|
remoteWorkflowToggles,
|
|
remoteConfigSettings,
|
|
mcpServers,
|
|
globalSkills,
|
|
localSkills,
|
|
])
|
|
|
|
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 onModeToggle = useCallback(() => {
|
|
void (async () => {
|
|
const convertedProtoMode = mode === "plan" ? PlanActMode.ACT : PlanActMode.PLAN
|
|
const response = await StateServiceClient.togglePlanActModeProto(
|
|
TogglePlanActModeRequest.create({
|
|
mode: convertedProtoMode,
|
|
chatContent: {
|
|
message: inputValue.trim() ? inputValue : undefined,
|
|
images: selectedImages,
|
|
files: selectedFiles,
|
|
},
|
|
}),
|
|
)
|
|
// Focus the textarea after mode toggle with slight delay
|
|
setTimeout(() => {
|
|
if (response.value) {
|
|
setInputValue("")
|
|
}
|
|
textAreaRef.current?.focus()
|
|
}, 100)
|
|
})()
|
|
}, [mode, inputValue, selectedImages, selectedFiles, setInputValue])
|
|
|
|
useShortcut(usePlatform().togglePlanActKeys, onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here
|
|
|
|
const handleContextButtonClick = useCallback(() => {
|
|
// Focus the textarea first
|
|
textAreaRef.current?.focus()
|
|
|
|
// If input is empty, just insert @
|
|
if (!inputValue.trim()) {
|
|
const event = {
|
|
target: {
|
|
value: "@",
|
|
selectionStart: 1,
|
|
},
|
|
} as React.ChangeEvent<HTMLTextAreaElement>
|
|
handleInputChange(event)
|
|
updateHighlights()
|
|
return
|
|
}
|
|
|
|
// If input ends with space or is empty, just append @
|
|
if (inputValue.endsWith(" ")) {
|
|
const event = {
|
|
target: {
|
|
value: inputValue + "@",
|
|
selectionStart: inputValue.length + 1,
|
|
},
|
|
} as React.ChangeEvent<HTMLTextAreaElement>
|
|
handleInputChange(event)
|
|
updateHighlights()
|
|
return
|
|
}
|
|
|
|
// Otherwise add space then @
|
|
const event = {
|
|
target: {
|
|
value: inputValue + " @",
|
|
selectionStart: inputValue.length + 2,
|
|
},
|
|
} as React.ChangeEvent<HTMLTextAreaElement>
|
|
handleInputChange(event)
|
|
updateHighlights()
|
|
}, [inputValue, handleInputChange, updateHighlights])
|
|
|
|
const handleModelButtonClick = () => {
|
|
navigateToSettingsModelPicker({ targetSection: "api-config" })
|
|
}
|
|
|
|
// Get model display name
|
|
const modelDisplayName = useMemo(() => {
|
|
const { selectedProvider, selectedModelId } = normalizeApiConfiguration(apiConfiguration, mode)
|
|
const {
|
|
vsCodeLmModelSelector,
|
|
togetherModelId,
|
|
lmStudioModelId,
|
|
ollamaModelId,
|
|
liteLlmModelId,
|
|
requestyModelId,
|
|
vercelAiGatewayModelId,
|
|
} = getModeSpecificFields(apiConfiguration, mode)
|
|
const unknownModel = "unknown"
|
|
|
|
if (!apiConfiguration) {
|
|
return unknownModel
|
|
}
|
|
switch (selectedProvider) {
|
|
case "cline":
|
|
return `${selectedProvider}:${selectedModelId}`
|
|
case "openai":
|
|
return `openai-compat:${selectedModelId}`
|
|
case "vscode-lm":
|
|
return `vscode-lm:${vsCodeLmModelSelector ? `${vsCodeLmModelSelector.vendor ?? ""}/${vsCodeLmModelSelector.family ?? ""}` : unknownModel}`
|
|
case "together":
|
|
return `${selectedProvider}:${togetherModelId}`
|
|
case "lmstudio":
|
|
return `${selectedProvider}:${lmStudioModelId}`
|
|
case "ollama":
|
|
return `${selectedProvider}:${ollamaModelId}`
|
|
case "litellm":
|
|
return `${selectedProvider}:${liteLlmModelId}`
|
|
case "requesty":
|
|
return `${selectedProvider}:${requestyModelId}`
|
|
case "vercel-ai-gateway":
|
|
return `${selectedProvider}:${vercelAiGatewayModelId || selectedModelId}`
|
|
case "anthropic":
|
|
case "openrouter":
|
|
default:
|
|
return `${selectedProvider}:${selectedModelId}`
|
|
}
|
|
}, [apiConfiguration, mode])
|
|
|
|
// Function to show error message for unsupported files for drag and drop
|
|
const showUnsupportedFileErrorMessage = () => {
|
|
// Show error message for unsupported files
|
|
setShowUnsupportedFileError(true)
|
|
|
|
// Clear any existing timer
|
|
if (unsupportedFileTimerRef.current) {
|
|
clearTimeout(unsupportedFileTimerRef.current)
|
|
}
|
|
|
|
// Set timer to hide error after 3 seconds
|
|
unsupportedFileTimerRef.current = setTimeout(() => {
|
|
setShowUnsupportedFileError(false)
|
|
unsupportedFileTimerRef.current = null
|
|
}, 3000)
|
|
}
|
|
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDraggingOver(true)
|
|
|
|
// Check if files are being dragged
|
|
if (e.dataTransfer.types.includes("Files")) {
|
|
// Check if any of the files are not images
|
|
const items = Array.from(e.dataTransfer.items)
|
|
const hasNonImageFile = items.some((item) => {
|
|
if (item.kind === "file") {
|
|
const type = item.type.split("/")[0]
|
|
return type !== "image"
|
|
}
|
|
return false
|
|
})
|
|
|
|
if (hasNonImageFile) {
|
|
showUnsupportedFileErrorMessage()
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Handles the drag over event to allow dropping.
|
|
* Prevents the default behavior to enable drop.
|
|
*
|
|
* @param {React.DragEvent} e - The drag event.
|
|
*/
|
|
const onDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
// Ensure state remains true if dragging continues over the element
|
|
if (!isDraggingOver) {
|
|
setIsDraggingOver(true)
|
|
}
|
|
}
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
// Check if the related target is still within the drop zone; prevents flickering
|
|
const dropZone = e.currentTarget as HTMLElement
|
|
if (!dropZone.contains(e.relatedTarget as Node)) {
|
|
setIsDraggingOver(false)
|
|
// Don't clear the error message here, let it time out naturally
|
|
}
|
|
}
|
|
|
|
// Effect to detect when drag operation ends outside the component
|
|
useEffect(() => {
|
|
const handleGlobalDragEnd = () => {
|
|
// This will be triggered when the drag operation ends anywhere
|
|
setIsDraggingOver(false)
|
|
// Don't clear error message, let it time out naturally
|
|
}
|
|
|
|
document.addEventListener("dragend", handleGlobalDragEnd)
|
|
|
|
return () => {
|
|
document.removeEventListener("dragend", handleGlobalDragEnd)
|
|
}
|
|
}, [])
|
|
|
|
/**
|
|
* Handles the drop event for files and text.
|
|
* Processes dropped images and text, updating the state accordingly.
|
|
*
|
|
* @param {React.DragEvent} e - The drop event.
|
|
*/
|
|
const onDrop = async (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDraggingOver(false) // Reset state on drop
|
|
|
|
// Clear any error message when something is actually dropped
|
|
setShowUnsupportedFileError(false)
|
|
if (unsupportedFileTimerRef.current) {
|
|
clearTimeout(unsupportedFileTimerRef.current)
|
|
unsupportedFileTimerRef.current = null
|
|
}
|
|
|
|
// --- 1. VSCode Explorer Drop Handling ---
|
|
let uris: string[] = []
|
|
const resourceUrlsData = e.dataTransfer.getData("resourceurls")
|
|
const vscodeUriListData = e.dataTransfer.getData("application/vnd.code.uri-list")
|
|
|
|
// 1a. Try 'resourceurls' first (used for multi-select)
|
|
if (resourceUrlsData) {
|
|
try {
|
|
uris = JSON.parse(resourceUrlsData)
|
|
uris = uris.map((uri) => decodeURIComponent(uri))
|
|
} catch (error) {
|
|
console.error("Failed to parse resourceurls JSON:", error)
|
|
uris = [] // Reset if parsing failed
|
|
}
|
|
}
|
|
|
|
// 1b. Fallback to 'application/vnd.code.uri-list' (newline separated)
|
|
if (uris.length === 0 && vscodeUriListData) {
|
|
uris = vscodeUriListData.split("\n").map((uri) => uri.trim())
|
|
}
|
|
|
|
// 1c. Filter for valid schemes (file or vscode-file) and non-empty strings
|
|
const validUris = uris.filter(
|
|
(uri) => uri && (uri.startsWith("vscode-file:") || uri.startsWith("file:") || uri.startsWith("vscode-remote:")),
|
|
)
|
|
|
|
if (validUris.length > 0) {
|
|
setPendingInsertions([])
|
|
let initialCursorPos = inputValue.length
|
|
if (textAreaRef.current) {
|
|
initialCursorPos = textAreaRef.current.selectionStart
|
|
}
|
|
setIntendedCursorPosition(initialCursorPos)
|
|
|
|
FileServiceClient.getRelativePaths(RelativePathsRequest.create({ uris: validUris }))
|
|
.then((response) => {
|
|
if (response.paths.length > 0) {
|
|
setPendingInsertions((prev) => [...prev, ...response.paths])
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error getting relative paths:", error)
|
|
})
|
|
return
|
|
}
|
|
|
|
const text = e.dataTransfer.getData("text")
|
|
if (text) {
|
|
handleTextDrop(text)
|
|
return
|
|
}
|
|
|
|
// --- 3. Image Drop Handling ---
|
|
// Only proceed if it wasn't a VSCode resource or plain text drop
|
|
const files = Array.from(e.dataTransfer.files)
|
|
const acceptedTypes = ["png", "jpeg", "webp"]
|
|
const imageFiles = files.filter((file) => {
|
|
const [type, subtype] = file.type.split("/")
|
|
return type === "image" && acceptedTypes.includes(subtype)
|
|
})
|
|
|
|
if (shouldDisableFilesAndImages || imageFiles.length === 0) {
|
|
return
|
|
}
|
|
|
|
const imageDataArray = await readImageFiles(imageFiles)
|
|
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
|
|
|
if (dataUrls.length > 0) {
|
|
const filesAndImagesLength = selectedImages.length + selectedFiles.length
|
|
const availableSlots = MAX_IMAGES_AND_FILES_PER_MESSAGE - filesAndImagesLength
|
|
|
|
if (availableSlots > 0) {
|
|
const imagesToAdd = Math.min(dataUrls.length, availableSlots)
|
|
setSelectedImages((prevImages) => [...prevImages, ...dataUrls.slice(0, imagesToAdd)])
|
|
}
|
|
} else {
|
|
console.warn("No valid images were processed")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the drop event for text.
|
|
* Inserts the dropped text at the current cursor position.
|
|
*
|
|
* @param {string} text - The dropped text.
|
|
*/
|
|
const handleTextDrop = (text: string) => {
|
|
const newValue = inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
|
|
setInputValue(newValue)
|
|
const newCursorPosition = cursorPosition + text.length
|
|
setCursorPosition(newCursorPosition)
|
|
setIntendedCursorPosition(newCursorPosition)
|
|
}
|
|
|
|
/**
|
|
* Reads image files and returns their data URLs.
|
|
* Uses FileReader to read the files as data URLs.
|
|
*
|
|
* @param {File[]} imageFiles - The image files to read.
|
|
* @returns {Promise<(string | null)[]>} - A promise that resolves to an array of data URLs or null values.
|
|
*/
|
|
const readImageFiles = (imageFiles: File[]): Promise<(string | null)[]> => {
|
|
return Promise.all(
|
|
imageFiles.map(
|
|
(file) =>
|
|
new Promise<string | null>((resolve) => {
|
|
const reader = new FileReader()
|
|
reader.onloadend = async () => {
|
|
// Make async
|
|
if (reader.error) {
|
|
console.error("Error reading file:", reader.error)
|
|
resolve(null)
|
|
} else {
|
|
const result = reader.result
|
|
if (typeof result === "string") {
|
|
try {
|
|
await getImageDimensions(result) // Check dimensions
|
|
resolve(result)
|
|
} catch (error) {
|
|
console.warn((error as Error).message)
|
|
showDimensionErrorMessage() // Show error to user
|
|
resolve(null) // Don't add this image
|
|
}
|
|
} else {
|
|
resolve(null)
|
|
}
|
|
}
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
// Replace Meta with the platform specific key and uppercase the command letter.
|
|
const togglePlanActKeys = usePlatform()
|
|
.togglePlanActKeys.replace("Meta", metaKeyChar)
|
|
.replace(/.$/, (match) => match.toUpperCase())
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
className="relative flex transition-colors ease-in-out duration-100 px-3.5 py-2.5"
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}>
|
|
{isVoiceRecording && (
|
|
<div
|
|
className={cn(
|
|
"absolute pointer-events-none z-10 overflow-hidden rounded-xs transition-all ease-in-out duration-300 left-3.5 right-3.5 top-2.5 bottom-2.5",
|
|
)}>
|
|
<PulsingBorder
|
|
bloom={1}
|
|
className="w-full h-full"
|
|
colorBack={"rgba(0,0,0,0)"}
|
|
colors={[
|
|
"#9d57fa", // purple
|
|
"#57c7fa", // cyan
|
|
"#fa57a8", // pink
|
|
"#9d57fa", // purple again for smooth loop
|
|
]}
|
|
intensity={0.4}
|
|
pulse={0.3}
|
|
roundness={0} // Match textarea border radius
|
|
scale={1.0}
|
|
smoke={0.25}
|
|
smokeSize={0.8}
|
|
softness={0.8}
|
|
speed={1.5}
|
|
spotSize={0.5}
|
|
spots={4}
|
|
thickness={0.1}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{showDimensionError && (
|
|
<div className="absolute inset-2.5 bg-[rgba(var(--vscode-errorForeground-rgb),0.1)] border-2 border-error rounded-xs flex items-center justify-center z-10 pointer-events-none">
|
|
<span className="text-error font-bold text-xs text-center">Image dimensions exceed 7500px</span>
|
|
</div>
|
|
)}
|
|
{showUnsupportedFileError && (
|
|
<div className="absolute inset-2.5 bg-[rgba(var(--vscode-errorForeground-rgb),0.1)] border-2 border-error rounded-xs flex items-center justify-center z-10 pointer-events-none">
|
|
<span className="text-error font-bold text-xs">Files other than images are currently disabled</span>
|
|
</div>
|
|
)}
|
|
{showSlashCommandsMenu && (
|
|
<div ref={slashCommandsMenuContainerRef}>
|
|
<SlashCommandMenu
|
|
globalSkills={globalSkills}
|
|
globalWorkflowToggles={globalWorkflowToggles}
|
|
localSkills={localSkills}
|
|
localWorkflowToggles={localWorkflowToggles}
|
|
mcpServers={mcpServers}
|
|
onMouseDown={handleMenuMouseDown}
|
|
onSelect={handleSlashCommandsSelect}
|
|
query={slashCommandsQuery}
|
|
remoteWorkflows={remoteConfigSettings?.remoteGlobalWorkflows}
|
|
remoteWorkflowToggles={remoteWorkflowToggles}
|
|
selectedIndex={selectedSlashCommandsIndex}
|
|
setSelectedIndex={setSelectedSlashCommandsIndex}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{showContextMenu && (
|
|
<div ref={contextMenuContainerRef}>
|
|
<ContextMenu
|
|
dynamicSearchResults={fileSearchResults}
|
|
isLoading={searchLoading}
|
|
onMouseDown={handleMenuMouseDown}
|
|
onSelect={handleMentionSelect}
|
|
queryItems={queryItems}
|
|
searchQuery={searchQuery}
|
|
selectedIndex={selectedMenuIndex}
|
|
selectedType={selectedType}
|
|
setSelectedIndex={setSelectedMenuIndex}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"absolute bottom-2.5 top-2.5 whitespace-pre-wrap break-words rounded-xs overflow-hidden bg-input-background",
|
|
isTextAreaFocused || isVoiceRecording
|
|
? "left-3.5 right-3.5"
|
|
: "left-3.5 right-3.5 border border-input-border",
|
|
)}
|
|
ref={highlightLayerRef}
|
|
style={{
|
|
position: "absolute",
|
|
pointerEvents: "none",
|
|
whiteSpace: "pre-wrap",
|
|
wordWrap: "break-word",
|
|
color: "transparent",
|
|
overflow: "hidden",
|
|
fontFamily: "var(--vscode-font-family)",
|
|
fontSize: "var(--vscode-editor-font-size)",
|
|
lineHeight: "var(--vscode-editor-line-height)",
|
|
borderRadius: 2,
|
|
borderLeft: isTextAreaFocused || isVoiceRecording ? 0 : undefined,
|
|
borderRight: isTextAreaFocused || isVoiceRecording ? 0 : undefined,
|
|
borderTop: isTextAreaFocused || isVoiceRecording ? 0 : undefined,
|
|
borderBottom: isTextAreaFocused || isVoiceRecording ? 0 : undefined,
|
|
padding: `9px ${dictationSettings?.dictationEnabled ? "48" : "28"}px ${9 + thumbnailsHeight}px 9px`,
|
|
}}
|
|
/>
|
|
<DynamicTextArea
|
|
autoFocus={true}
|
|
data-testid="chat-input"
|
|
maxRows={10}
|
|
minRows={3}
|
|
onBlur={handleBlur}
|
|
onChange={(e) => {
|
|
handleInputChange(e)
|
|
updateHighlights()
|
|
}}
|
|
onFocus={() => {
|
|
setIsTextAreaFocused(true)
|
|
onFocusChange?.(true) // Call prop on focus
|
|
}}
|
|
onHeightChange={(height) => {
|
|
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
|
setTextAreaBaseHeight(height)
|
|
}
|
|
onHeightChange?.(height)
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onKeyUp={handleKeyUp}
|
|
onMouseUp={updateCursorPosition}
|
|
onPaste={handlePaste}
|
|
onScroll={() => updateHighlights()}
|
|
onSelect={updateCursorPosition}
|
|
placeholder={showUnsupportedFileError || showDimensionError ? "" : placeholderText}
|
|
ref={(el) => {
|
|
if (typeof ref === "function") {
|
|
ref(el)
|
|
} else if (ref) {
|
|
ref.current = el
|
|
}
|
|
textAreaRef.current = el
|
|
}}
|
|
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}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 ${dictationSettings?.dictationEnabled ? "48" : "28"}px 9px 9px`,
|
|
cursor: "text",
|
|
flex: 1,
|
|
zIndex: 1,
|
|
outline:
|
|
isDraggingOver && !showUnsupportedFileError // Only show drag outline if not showing error
|
|
? "2px dashed var(--vscode-focusBorder)"
|
|
: isTextAreaFocused
|
|
? `1px solid ${mode === "plan" ? PLAN_MODE_COLOR : "var(--vscode-focusBorder)"}`
|
|
: "none",
|
|
outlineOffset: isDraggingOver && !showUnsupportedFileError ? "1px" : "0px", // Add offset for drag-over outline
|
|
}}
|
|
value={inputValue}
|
|
/>
|
|
{!inputValue && selectedImages.length === 0 && selectedFiles.length === 0 && (
|
|
<div className="text-xs absolute bottom-5 left-6.5 right-16 text-(--vscode-input-placeholderForeground)/50 whitespace-nowrap overflow-hidden text-ellipsis pointer-events-none z-1">
|
|
Type @ for context, / for slash commands & workflows, hold shift to drag in files/images
|
|
</div>
|
|
)}
|
|
{(selectedImages.length > 0 || selectedFiles.length > 0) && (
|
|
<Thumbnails
|
|
files={selectedFiles}
|
|
images={selectedImages}
|
|
onHeightChange={handleThumbnailsHeightChange}
|
|
setFiles={setSelectedFiles}
|
|
setImages={setSelectedImages}
|
|
style={{
|
|
position: "absolute",
|
|
paddingTop: 4,
|
|
bottom: 14,
|
|
left: 22,
|
|
right: 47, // (54 + 9) + 4 extra padding
|
|
zIndex: 2,
|
|
}}
|
|
/>
|
|
)}
|
|
<div
|
|
className="absolute flex items-end bottom-4.5 right-5 z-10 h-8 text-xs"
|
|
style={{ height: textAreaBaseHeight }}>
|
|
<div className="flex flex-row items-center">
|
|
{dictationSettings?.dictationEnabled === true && dictationSettings?.featureEnabled && (
|
|
<VoiceRecorder
|
|
disabled={sendingDisabled}
|
|
isAuthenticated={!!clineUser?.uid}
|
|
language={dictationSettings?.dictationLanguage || "en"}
|
|
onProcessingStateChange={(isProcessing, message) => {
|
|
if (isProcessing && message) {
|
|
// Show processing message in input
|
|
setInputValue(`${inputValue} [${message}]`.trim())
|
|
}
|
|
// When processing is done, the onTranscription callback will handle the final text
|
|
}}
|
|
onRecordingStateChange={setIsVoiceRecording}
|
|
onTranscription={(text) => {
|
|
// Remove any processing text first
|
|
const processingPattern = /\s*\[Transcribing\.\.\.\]$/
|
|
const cleanedValue = inputValue.replace(processingPattern, "")
|
|
|
|
if (!text) {
|
|
setInputValue(cleanedValue)
|
|
return
|
|
}
|
|
|
|
// Append the transcribed text to the cleaned input
|
|
const newValue = cleanedValue + (cleanedValue ? " " : "") + text
|
|
setInputValue(newValue)
|
|
// Focus the textarea and move cursor to end
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.focus()
|
|
const length = newValue.length
|
|
textAreaRef.current.setSelectionRange(length, length)
|
|
}
|
|
}, 0)
|
|
}}
|
|
/>
|
|
)}
|
|
{!isVoiceRecording && (
|
|
<div
|
|
className={cn(
|
|
"input-icon-button",
|
|
{ disabled: sendingDisabled },
|
|
"codicon codicon-send text-sm",
|
|
)}
|
|
data-testid="send-button"
|
|
onClick={() => {
|
|
if (!sendingDisabled) {
|
|
setIsTextAreaFocused(false)
|
|
onSend()
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-between items-center -mt-[2px] px-3 pb-2">
|
|
{/* Always render both components, but control visibility with CSS */}
|
|
<div className="relative flex-1 min-w-0 h-5">
|
|
{/* ButtonGroup - always in DOM but visibility controlled */}
|
|
<ButtonGroup className="absolute top-0 left-0 right-0 ease-in-out w-full h-5 z-10 flex items-center">
|
|
<Tooltip>
|
|
<TooltipContent>Add Context</TooltipContent>
|
|
<TooltipTrigger>
|
|
<VSCodeButton
|
|
appearance="icon"
|
|
aria-label="Add Context"
|
|
className="p-0 m-0 flex items-center"
|
|
data-testid="context-button"
|
|
onClick={handleContextButtonClick}>
|
|
<ButtonContainer>
|
|
<AtSignIcon size={12} />
|
|
</ButtonContainer>
|
|
</VSCodeButton>
|
|
</TooltipTrigger>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipContent>Add Files & Images</TooltipContent>
|
|
<TooltipTrigger>
|
|
<VSCodeButton
|
|
appearance="icon"
|
|
aria-label="Add Files & Images"
|
|
className="p-0 m-0 flex items-center"
|
|
data-testid="files-button"
|
|
disabled={shouldDisableFilesAndImages}
|
|
onClick={() => {
|
|
if (!shouldDisableFilesAndImages) {
|
|
onSelectFilesAndImages()
|
|
}
|
|
}}>
|
|
<ButtonContainer>
|
|
<PlusIcon size={13} />
|
|
</ButtonContainer>
|
|
</VSCodeButton>
|
|
</TooltipTrigger>
|
|
</Tooltip>
|
|
|
|
<ServersToggleModal />
|
|
|
|
<ClineRulesToggleModal />
|
|
|
|
<ModelContainer>
|
|
<ModelButtonWrapper>
|
|
<ModelDisplayButton
|
|
disabled={false}
|
|
onClick={handleModelButtonClick}
|
|
role="button"
|
|
tabIndex={0}
|
|
title="Open API Settings">
|
|
<ModelButtonContent className="text-xs">{modelDisplayName}</ModelButtonContent>
|
|
</ModelDisplayButton>
|
|
</ModelButtonWrapper>
|
|
</ModelContainer>
|
|
</ButtonGroup>
|
|
</div>
|
|
{/* Tooltip for Plan/Act toggle remains outside the conditional rendering */}
|
|
<Tooltip>
|
|
<TooltipContent
|
|
className="text-xs px-2 flex flex-col gap-1"
|
|
hidden={shownTooltipMode === null}
|
|
side="top">
|
|
{`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
|
|
<p className="text-description/80 text-xs mb-0">
|
|
Toggle w/ <kbd className="text-muted-foreground mx-1">{togglePlanActKeys}</kbd>
|
|
</p>
|
|
</TooltipContent>
|
|
<TooltipTrigger>
|
|
<SwitchContainer data-testid="mode-switch" disabled={false} onClick={onModeToggle}>
|
|
<Slider isAct={mode === "act"} isPlan={mode === "plan"} />
|
|
{["Plan", "Act"].map((m) => (
|
|
<div
|
|
aria-checked={mode === m.toLowerCase()}
|
|
className={cn(
|
|
"pt-0.5 pb-px px-2 z-10 text-xs w-1/2 text-center bg-transparent",
|
|
mode === m.toLowerCase() ? "text-white" : "text-input-foreground",
|
|
)}
|
|
onMouseLeave={() => setShownTooltipMode(null)}
|
|
onMouseOver={() => setShownTooltipMode(m.toLowerCase() === "plan" ? "plan" : "act")}
|
|
role="switch">
|
|
{m}
|
|
</div>
|
|
))}
|
|
</SwitchContainer>
|
|
</TooltipTrigger>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
|
|
export default ChatTextArea
|