ChatTextArea.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914
  1. import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
  2. import DynamicTextArea from "react-textarea-autosize"
  3. import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
  4. import { useExtensionState } from "../../context/ExtensionStateContext"
  5. import {
  6. ContextMenuOptionType,
  7. getContextMenuOptions,
  8. insertMention,
  9. removeMention,
  10. shouldShowContextMenu,
  11. } from "../../utils/context-mentions"
  12. import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
  13. import ContextMenu from "./ContextMenu"
  14. import Thumbnails from "../common/Thumbnails"
  15. import { vscode } from "../../utils/vscode"
  16. import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
  17. import { Mode, getAllModes } from "../../../../src/shared/modes"
  18. import { CaretIcon } from "../common/CaretIcon"
  19. interface ChatTextAreaProps {
  20. inputValue: string
  21. setInputValue: (value: string) => void
  22. textAreaDisabled: boolean
  23. placeholderText: string
  24. selectedImages: string[]
  25. setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
  26. onSend: () => void
  27. onSelectImages: () => void
  28. shouldDisableImages: boolean
  29. onHeightChange?: (height: number) => void
  30. mode: Mode
  31. setMode: (value: Mode) => void
  32. }
  33. const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
  34. (
  35. {
  36. inputValue,
  37. setInputValue,
  38. textAreaDisabled,
  39. placeholderText,
  40. selectedImages,
  41. setSelectedImages,
  42. onSend,
  43. onSelectImages,
  44. shouldDisableImages,
  45. onHeightChange,
  46. mode,
  47. setMode,
  48. },
  49. ref,
  50. ) => {
  51. const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
  52. const [gitCommits, setGitCommits] = useState<any[]>([])
  53. const [showDropdown, setShowDropdown] = useState(false)
  54. // Close dropdown when clicking outside
  55. useEffect(() => {
  56. const handleClickOutside = (event: MouseEvent) => {
  57. if (showDropdown) {
  58. setShowDropdown(false)
  59. }
  60. }
  61. document.addEventListener("mousedown", handleClickOutside)
  62. return () => document.removeEventListener("mousedown", handleClickOutside)
  63. }, [showDropdown])
  64. // Handle enhanced prompt response
  65. useEffect(() => {
  66. const messageHandler = (event: MessageEvent) => {
  67. const message = event.data
  68. if (message.type === "enhancedPrompt") {
  69. if (message.text) {
  70. setInputValue(message.text)
  71. }
  72. setIsEnhancingPrompt(false)
  73. } else if (message.type === "commitSearchResults") {
  74. const commits = message.commits.map((commit: any) => ({
  75. type: ContextMenuOptionType.Git,
  76. value: commit.hash,
  77. label: commit.subject,
  78. description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
  79. icon: "$(git-commit)",
  80. }))
  81. setGitCommits(commits)
  82. }
  83. }
  84. window.addEventListener("message", messageHandler)
  85. return () => window.removeEventListener("message", messageHandler)
  86. }, [setInputValue])
  87. const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
  88. const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
  89. const [showContextMenu, setShowContextMenu] = useState(false)
  90. const [cursorPosition, setCursorPosition] = useState(0)
  91. const [searchQuery, setSearchQuery] = useState("")
  92. const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
  93. const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
  94. const highlightLayerRef = useRef<HTMLDivElement>(null)
  95. const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
  96. const [selectedType, setSelectedType] = useState<ContextMenuOptionType | null>(null)
  97. const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
  98. const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
  99. const contextMenuContainerRef = useRef<HTMLDivElement>(null)
  100. const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
  101. const [isFocused, setIsFocused] = useState(false)
  102. // Fetch git commits when Git is selected or when typing a hash
  103. useEffect(() => {
  104. if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) {
  105. const message: WebviewMessage = {
  106. type: "searchCommits",
  107. query: searchQuery || "",
  108. } as const
  109. vscode.postMessage(message)
  110. }
  111. }, [selectedType, searchQuery])
  112. const handleEnhancePrompt = useCallback(() => {
  113. if (!textAreaDisabled) {
  114. const trimmedInput = inputValue.trim()
  115. if (trimmedInput) {
  116. setIsEnhancingPrompt(true)
  117. const message = {
  118. type: "enhancePrompt" as const,
  119. text: trimmedInput,
  120. }
  121. vscode.postMessage(message)
  122. } else {
  123. const promptDescription =
  124. "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works."
  125. setInputValue(promptDescription)
  126. }
  127. }
  128. }, [inputValue, textAreaDisabled, setInputValue])
  129. const queryItems = useMemo(() => {
  130. return [
  131. { type: ContextMenuOptionType.Problems, value: "problems" },
  132. ...gitCommits,
  133. ...openedTabs
  134. .filter((tab) => tab.path)
  135. .map((tab) => ({
  136. type: ContextMenuOptionType.OpenedFile,
  137. value: "/" + tab.path,
  138. })),
  139. ...filePaths
  140. .map((file) => "/" + file)
  141. .filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs
  142. .map((path) => ({
  143. type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
  144. value: path,
  145. })),
  146. ]
  147. }, [filePaths, gitCommits, openedTabs])
  148. useEffect(() => {
  149. const handleClickOutside = (event: MouseEvent) => {
  150. if (
  151. contextMenuContainerRef.current &&
  152. !contextMenuContainerRef.current.contains(event.target as Node)
  153. ) {
  154. setShowContextMenu(false)
  155. }
  156. }
  157. if (showContextMenu) {
  158. document.addEventListener("mousedown", handleClickOutside)
  159. }
  160. return () => {
  161. document.removeEventListener("mousedown", handleClickOutside)
  162. }
  163. }, [showContextMenu, setShowContextMenu])
  164. const handleMentionSelect = useCallback(
  165. (type: ContextMenuOptionType, value?: string) => {
  166. if (type === ContextMenuOptionType.NoResults) {
  167. return
  168. }
  169. if (type === ContextMenuOptionType.Mode && value) {
  170. // Handle mode selection
  171. setMode(value)
  172. setInputValue("")
  173. setShowContextMenu(false)
  174. vscode.postMessage({
  175. type: "mode",
  176. text: value,
  177. })
  178. return
  179. }
  180. if (
  181. type === ContextMenuOptionType.File ||
  182. type === ContextMenuOptionType.Folder ||
  183. type === ContextMenuOptionType.Git
  184. ) {
  185. if (!value) {
  186. setSelectedType(type)
  187. setSearchQuery("")
  188. setSelectedMenuIndex(0)
  189. return
  190. }
  191. }
  192. setShowContextMenu(false)
  193. setSelectedType(null)
  194. if (textAreaRef.current) {
  195. let insertValue = value || ""
  196. if (type === ContextMenuOptionType.URL) {
  197. insertValue = value || ""
  198. } else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
  199. insertValue = value || ""
  200. } else if (type === ContextMenuOptionType.Problems) {
  201. insertValue = "problems"
  202. } else if (type === ContextMenuOptionType.Git) {
  203. insertValue = value || ""
  204. }
  205. const { newValue, mentionIndex } = insertMention(
  206. textAreaRef.current.value,
  207. cursorPosition,
  208. insertValue,
  209. )
  210. setInputValue(newValue)
  211. const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
  212. setCursorPosition(newCursorPosition)
  213. setIntendedCursorPosition(newCursorPosition)
  214. // scroll to cursor
  215. setTimeout(() => {
  216. if (textAreaRef.current) {
  217. textAreaRef.current.blur()
  218. textAreaRef.current.focus()
  219. }
  220. }, 0)
  221. }
  222. },
  223. // eslint-disable-next-line react-hooks/exhaustive-deps
  224. [setInputValue, cursorPosition],
  225. )
  226. const handleKeyDown = useCallback(
  227. (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
  228. if (showContextMenu) {
  229. if (event.key === "Escape") {
  230. setSelectedType(null)
  231. setSelectedMenuIndex(3) // File by default
  232. return
  233. }
  234. if (event.key === "ArrowUp" || event.key === "ArrowDown") {
  235. event.preventDefault()
  236. setSelectedMenuIndex((prevIndex) => {
  237. const direction = event.key === "ArrowUp" ? -1 : 1
  238. const options = getContextMenuOptions(
  239. searchQuery,
  240. selectedType,
  241. queryItems,
  242. getAllModes(customModes),
  243. )
  244. const optionsLength = options.length
  245. if (optionsLength === 0) return prevIndex
  246. // Find selectable options (non-URL types)
  247. const selectableOptions = options.filter(
  248. (option) =>
  249. option.type !== ContextMenuOptionType.URL &&
  250. option.type !== ContextMenuOptionType.NoResults,
  251. )
  252. if (selectableOptions.length === 0) return -1 // No selectable options
  253. // Find the index of the next selectable option
  254. const currentSelectableIndex = selectableOptions.findIndex(
  255. (option) => option === options[prevIndex],
  256. )
  257. const newSelectableIndex =
  258. (currentSelectableIndex + direction + selectableOptions.length) %
  259. selectableOptions.length
  260. // Find the index of the selected option in the original options array
  261. return options.findIndex((option) => option === selectableOptions[newSelectableIndex])
  262. })
  263. return
  264. }
  265. if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
  266. event.preventDefault()
  267. const selectedOption = getContextMenuOptions(
  268. searchQuery,
  269. selectedType,
  270. queryItems,
  271. getAllModes(customModes),
  272. )[selectedMenuIndex]
  273. if (
  274. selectedOption &&
  275. selectedOption.type !== ContextMenuOptionType.URL &&
  276. selectedOption.type !== ContextMenuOptionType.NoResults
  277. ) {
  278. handleMentionSelect(selectedOption.type, selectedOption.value)
  279. }
  280. return
  281. }
  282. }
  283. const isComposing = event.nativeEvent?.isComposing ?? false
  284. if (event.key === "Enter" && !event.shiftKey && !isComposing) {
  285. event.preventDefault()
  286. onSend()
  287. }
  288. if (event.key === "Backspace" && !isComposing) {
  289. const charBeforeCursor = inputValue[cursorPosition - 1]
  290. const charAfterCursor = inputValue[cursorPosition + 1]
  291. const charBeforeIsWhitespace =
  292. charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n"
  293. const charAfterIsWhitespace =
  294. charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n"
  295. // checks if char before cusor is whitespace after a mention
  296. if (
  297. charBeforeIsWhitespace &&
  298. inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string
  299. ) {
  300. const newCursorPosition = cursorPosition - 1
  301. // 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
  302. if (!charAfterIsWhitespace) {
  303. event.preventDefault()
  304. textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition)
  305. setCursorPosition(newCursorPosition)
  306. }
  307. setCursorPosition(newCursorPosition)
  308. setJustDeletedSpaceAfterMention(true)
  309. } else if (justDeletedSpaceAfterMention) {
  310. const { newText, newPosition } = removeMention(inputValue, cursorPosition)
  311. if (newText !== inputValue) {
  312. event.preventDefault()
  313. setInputValue(newText)
  314. setIntendedCursorPosition(newPosition) // Store the new cursor position in state
  315. }
  316. setJustDeletedSpaceAfterMention(false)
  317. setShowContextMenu(false)
  318. } else {
  319. setJustDeletedSpaceAfterMention(false)
  320. }
  321. }
  322. },
  323. [
  324. onSend,
  325. showContextMenu,
  326. searchQuery,
  327. selectedMenuIndex,
  328. handleMentionSelect,
  329. selectedType,
  330. inputValue,
  331. cursorPosition,
  332. setInputValue,
  333. justDeletedSpaceAfterMention,
  334. queryItems,
  335. customModes,
  336. ],
  337. )
  338. useLayoutEffect(() => {
  339. if (intendedCursorPosition !== null && textAreaRef.current) {
  340. textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
  341. setIntendedCursorPosition(null) // Reset the state
  342. }
  343. }, [inputValue, intendedCursorPosition])
  344. const handleInputChange = useCallback(
  345. (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  346. const newValue = e.target.value
  347. const newCursorPosition = e.target.selectionStart
  348. setInputValue(newValue)
  349. setCursorPosition(newCursorPosition)
  350. const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
  351. setShowContextMenu(showMenu)
  352. if (showMenu) {
  353. if (newValue.startsWith("/")) {
  354. // Handle slash command
  355. const query = newValue
  356. setSearchQuery(query)
  357. setSelectedMenuIndex(0)
  358. } else {
  359. // Existing @ mention handling
  360. const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
  361. const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
  362. setSearchQuery(query)
  363. if (query.length > 0) {
  364. setSelectedMenuIndex(0)
  365. } else {
  366. setSelectedMenuIndex(3) // Set to "File" option by default
  367. }
  368. }
  369. } else {
  370. setSearchQuery("")
  371. setSelectedMenuIndex(-1)
  372. }
  373. },
  374. [setInputValue],
  375. )
  376. useEffect(() => {
  377. if (!showContextMenu) {
  378. setSelectedType(null)
  379. }
  380. }, [showContextMenu])
  381. const handleBlur = useCallback(() => {
  382. // Only hide the context menu if the user didn't click on it
  383. if (!isMouseDownOnMenu) {
  384. setShowContextMenu(false)
  385. }
  386. setIsFocused(false)
  387. }, [isMouseDownOnMenu])
  388. const handlePaste = useCallback(
  389. async (e: React.ClipboardEvent) => {
  390. const items = e.clipboardData.items
  391. const pastedText = e.clipboardData.getData("text")
  392. // Check if the pasted content is a URL, add space after so user can easily delete if they don't want it
  393. const urlRegex = /^\S+:\/\/\S+$/
  394. if (urlRegex.test(pastedText.trim())) {
  395. e.preventDefault()
  396. const trimmedUrl = pastedText.trim()
  397. const newValue =
  398. inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition)
  399. setInputValue(newValue)
  400. const newCursorPosition = cursorPosition + trimmedUrl.length + 1
  401. setCursorPosition(newCursorPosition)
  402. setIntendedCursorPosition(newCursorPosition)
  403. setShowContextMenu(false)
  404. // Scroll to new cursor position
  405. setTimeout(() => {
  406. if (textAreaRef.current) {
  407. textAreaRef.current.blur()
  408. textAreaRef.current.focus()
  409. }
  410. }, 0)
  411. return
  412. }
  413. const acceptedTypes = ["png", "jpeg", "webp"]
  414. const imageItems = Array.from(items).filter((item) => {
  415. const [type, subtype] = item.type.split("/")
  416. return type === "image" && acceptedTypes.includes(subtype)
  417. })
  418. if (!shouldDisableImages && imageItems.length > 0) {
  419. e.preventDefault()
  420. const imagePromises = imageItems.map((item) => {
  421. return new Promise<string | null>((resolve) => {
  422. const blob = item.getAsFile()
  423. if (!blob) {
  424. resolve(null)
  425. return
  426. }
  427. const reader = new FileReader()
  428. reader.onloadend = () => {
  429. if (reader.error) {
  430. console.error("Error reading file:", reader.error)
  431. resolve(null)
  432. } else {
  433. const result = reader.result
  434. resolve(typeof result === "string" ? result : null)
  435. }
  436. }
  437. reader.readAsDataURL(blob)
  438. })
  439. })
  440. const imageDataArray = await Promise.all(imagePromises)
  441. const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
  442. if (dataUrls.length > 0) {
  443. setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
  444. } else {
  445. console.warn("No valid images were processed")
  446. }
  447. }
  448. },
  449. [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue],
  450. )
  451. const handleThumbnailsHeightChange = useCallback((height: number) => {
  452. setThumbnailsHeight(height)
  453. }, [])
  454. useEffect(() => {
  455. if (selectedImages.length === 0) {
  456. setThumbnailsHeight(0)
  457. }
  458. }, [selectedImages])
  459. const handleMenuMouseDown = useCallback(() => {
  460. setIsMouseDownOnMenu(true)
  461. }, [])
  462. const updateHighlights = useCallback(() => {
  463. if (!textAreaRef.current || !highlightLayerRef.current) return
  464. const text = textAreaRef.current.value
  465. highlightLayerRef.current.innerHTML = text
  466. .replace(/\n$/, "\n\n")
  467. .replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
  468. .replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
  469. highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
  470. highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
  471. }, [])
  472. useLayoutEffect(() => {
  473. updateHighlights()
  474. }, [inputValue, updateHighlights])
  475. const updateCursorPosition = useCallback(() => {
  476. if (textAreaRef.current) {
  477. setCursorPosition(textAreaRef.current.selectionStart)
  478. }
  479. }, [])
  480. const handleKeyUp = useCallback(
  481. (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  482. if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
  483. updateCursorPosition()
  484. }
  485. },
  486. [updateCursorPosition],
  487. )
  488. const selectStyle = {
  489. fontSize: "11px",
  490. cursor: textAreaDisabled ? "not-allowed" : "pointer",
  491. backgroundColor: "transparent",
  492. border: "none",
  493. color: "var(--vscode-foreground)",
  494. opacity: textAreaDisabled ? 0.5 : 0.8,
  495. outline: "none",
  496. paddingLeft: "20px",
  497. paddingRight: "6px",
  498. WebkitAppearance: "none" as const,
  499. MozAppearance: "none" as const,
  500. appearance: "none" as const,
  501. }
  502. const optionStyle = {
  503. backgroundColor: "var(--vscode-dropdown-background)",
  504. color: "var(--vscode-dropdown-foreground)",
  505. }
  506. const caretContainerStyle = {
  507. position: "absolute" as const,
  508. left: 6,
  509. top: "50%",
  510. transform: "translateY(-45%)",
  511. pointerEvents: "none" as const,
  512. opacity: textAreaDisabled ? 0.5 : 0.8,
  513. }
  514. return (
  515. <div
  516. className="chat-text-area"
  517. style={{
  518. opacity: textAreaDisabled ? 0.5 : 1,
  519. position: "relative",
  520. display: "flex",
  521. flexDirection: "column",
  522. gap: "8px",
  523. backgroundColor: "var(--vscode-input-background)",
  524. margin: "10px 15px",
  525. padding: "8px",
  526. outline: "none",
  527. border: "1px solid",
  528. borderColor: isFocused ? "var(--vscode-focusBorder)" : "transparent",
  529. borderRadius: "2px",
  530. }}
  531. onDrop={async (e) => {
  532. e.preventDefault()
  533. const files = Array.from(e.dataTransfer.files)
  534. const text = e.dataTransfer.getData("text")
  535. if (text) {
  536. const newValue = inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
  537. setInputValue(newValue)
  538. const newCursorPosition = cursorPosition + text.length
  539. setCursorPosition(newCursorPosition)
  540. setIntendedCursorPosition(newCursorPosition)
  541. return
  542. }
  543. const acceptedTypes = ["png", "jpeg", "webp"]
  544. const imageFiles = files.filter((file) => {
  545. const [type, subtype] = file.type.split("/")
  546. return type === "image" && acceptedTypes.includes(subtype)
  547. })
  548. if (!shouldDisableImages && imageFiles.length > 0) {
  549. const imagePromises = imageFiles.map((file) => {
  550. return new Promise<string | null>((resolve) => {
  551. const reader = new FileReader()
  552. reader.onloadend = () => {
  553. if (reader.error) {
  554. console.error("Error reading file:", reader.error)
  555. resolve(null)
  556. } else {
  557. const result = reader.result
  558. resolve(typeof result === "string" ? result : null)
  559. }
  560. }
  561. reader.readAsDataURL(file)
  562. })
  563. })
  564. const imageDataArray = await Promise.all(imagePromises)
  565. const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
  566. if (dataUrls.length > 0) {
  567. setSelectedImages((prevImages) =>
  568. [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE),
  569. )
  570. if (typeof vscode !== "undefined") {
  571. vscode.postMessage({
  572. type: "draggedImages",
  573. dataUrls: dataUrls,
  574. })
  575. }
  576. } else {
  577. console.warn("No valid images were processed")
  578. }
  579. }
  580. }}
  581. onDragOver={(e) => {
  582. e.preventDefault()
  583. }}>
  584. {showContextMenu && (
  585. <div ref={contextMenuContainerRef}>
  586. <ContextMenu
  587. onSelect={handleMentionSelect}
  588. searchQuery={searchQuery}
  589. onMouseDown={handleMenuMouseDown}
  590. selectedIndex={selectedMenuIndex}
  591. setSelectedIndex={setSelectedMenuIndex}
  592. selectedType={selectedType}
  593. queryItems={queryItems}
  594. modes={getAllModes(customModes)}
  595. />
  596. </div>
  597. )}
  598. <div
  599. style={{
  600. position: "relative",
  601. flex: "1 1 auto",
  602. display: "flex",
  603. flexDirection: "column-reverse",
  604. minHeight: 0,
  605. overflow: "hidden",
  606. }}>
  607. <div
  608. ref={highlightLayerRef}
  609. style={{
  610. position: "absolute",
  611. inset: 0,
  612. pointerEvents: "none",
  613. whiteSpace: "pre-wrap",
  614. wordWrap: "break-word",
  615. color: "transparent",
  616. overflow: "hidden",
  617. fontFamily: "var(--vscode-font-family)",
  618. fontSize: "var(--vscode-editor-font-size)",
  619. lineHeight: "var(--vscode-editor-line-height)",
  620. padding: "2px",
  621. paddingRight: "8px",
  622. marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
  623. zIndex: 1,
  624. }}
  625. />
  626. <DynamicTextArea
  627. ref={(el) => {
  628. if (typeof ref === "function") {
  629. ref(el)
  630. } else if (ref) {
  631. ref.current = el
  632. }
  633. textAreaRef.current = el
  634. }}
  635. value={inputValue}
  636. disabled={textAreaDisabled}
  637. onChange={(e) => {
  638. handleInputChange(e)
  639. updateHighlights()
  640. }}
  641. onFocus={() => setIsFocused(true)}
  642. onKeyDown={handleKeyDown}
  643. onKeyUp={handleKeyUp}
  644. onBlur={handleBlur}
  645. onPaste={handlePaste}
  646. onSelect={updateCursorPosition}
  647. onMouseUp={updateCursorPosition}
  648. onHeightChange={(height) => {
  649. if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
  650. setTextAreaBaseHeight(height)
  651. }
  652. onHeightChange?.(height)
  653. }}
  654. placeholder={placeholderText}
  655. minRows={3}
  656. maxRows={15}
  657. autoFocus={true}
  658. style={{
  659. width: "100%",
  660. outline: "none",
  661. boxSizing: "border-box",
  662. backgroundColor: "transparent",
  663. color: "var(--vscode-input-foreground)",
  664. borderRadius: 2,
  665. fontFamily: "var(--vscode-font-family)",
  666. fontSize: "var(--vscode-editor-font-size)",
  667. lineHeight: "var(--vscode-editor-line-height)",
  668. resize: "none",
  669. overflowX: "hidden",
  670. overflowY: "auto",
  671. border: "none",
  672. padding: "2px",
  673. paddingRight: "8px",
  674. marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
  675. cursor: textAreaDisabled ? "not-allowed" : undefined,
  676. flex: "0 1 auto",
  677. zIndex: 2,
  678. scrollbarWidth: "none",
  679. }}
  680. onScroll={() => updateHighlights()}
  681. />
  682. </div>
  683. {selectedImages.length > 0 && (
  684. <Thumbnails
  685. images={selectedImages}
  686. setImages={setSelectedImages}
  687. onHeightChange={handleThumbnailsHeightChange}
  688. style={{
  689. position: "absolute",
  690. bottom: "36px",
  691. left: "16px",
  692. zIndex: 2,
  693. marginBottom: "4px",
  694. }}
  695. />
  696. )}
  697. <div
  698. style={{
  699. display: "flex",
  700. justifyContent: "space-between",
  701. alignItems: "center",
  702. marginTop: "auto",
  703. paddingTop: "2px",
  704. }}>
  705. <div
  706. style={{
  707. display: "flex",
  708. alignItems: "center",
  709. }}>
  710. <div style={{ position: "relative", display: "inline-block" }}>
  711. <select
  712. value={mode}
  713. disabled={textAreaDisabled}
  714. onChange={(e) => {
  715. const value = e.target.value
  716. if (value === "prompts-action") {
  717. window.postMessage({ type: "action", action: "promptsButtonClicked" })
  718. return
  719. }
  720. setMode(value as Mode)
  721. vscode.postMessage({
  722. type: "mode",
  723. text: value,
  724. })
  725. }}
  726. style={{
  727. ...selectStyle,
  728. minWidth: "70px",
  729. flex: "0 0 auto",
  730. }}>
  731. {getAllModes(customModes).map((mode) => (
  732. <option key={mode.slug} value={mode.slug} style={{ ...optionStyle }}>
  733. {mode.name}
  734. </option>
  735. ))}
  736. <option
  737. disabled
  738. style={{
  739. borderTop: "1px solid var(--vscode-dropdown-border)",
  740. ...optionStyle,
  741. }}>
  742. ────
  743. </option>
  744. <option value="prompts-action" style={{ ...optionStyle }}>
  745. Edit...
  746. </option>
  747. </select>
  748. <div style={caretContainerStyle}>
  749. <CaretIcon />
  750. </div>
  751. </div>
  752. <div
  753. style={{
  754. position: "relative",
  755. display: "inline-block",
  756. flex: "1 1 auto",
  757. minWidth: 0,
  758. maxWidth: "150px",
  759. overflow: "hidden",
  760. }}>
  761. <select
  762. value={currentApiConfigName || ""}
  763. disabled={textAreaDisabled}
  764. onChange={(e) => {
  765. const value = e.target.value
  766. if (value === "settings-action") {
  767. window.postMessage({ type: "action", action: "settingsButtonClicked" })
  768. return
  769. }
  770. vscode.postMessage({
  771. type: "loadApiConfiguration",
  772. text: value,
  773. })
  774. }}
  775. style={{
  776. ...selectStyle,
  777. width: "100%",
  778. textOverflow: "ellipsis",
  779. }}>
  780. {(listApiConfigMeta || []).map((config) => (
  781. <option
  782. key={config.name}
  783. value={config.name}
  784. style={{
  785. ...optionStyle,
  786. }}>
  787. {config.name}
  788. </option>
  789. ))}
  790. <option
  791. disabled
  792. style={{
  793. borderTop: "1px solid var(--vscode-dropdown-border)",
  794. ...optionStyle,
  795. }}>
  796. ────
  797. </option>
  798. <option value="settings-action" style={{ ...optionStyle }}>
  799. Edit...
  800. </option>
  801. </select>
  802. <div style={caretContainerStyle}>
  803. <CaretIcon />
  804. </div>
  805. </div>
  806. </div>
  807. <div
  808. style={{
  809. display: "flex",
  810. alignItems: "center",
  811. gap: "12px",
  812. }}>
  813. <div style={{ display: "flex", alignItems: "center" }}>
  814. {isEnhancingPrompt ? (
  815. <span
  816. className="codicon codicon-loading codicon-modifier-spin"
  817. style={{
  818. color: "var(--vscode-input-foreground)",
  819. opacity: 0.5,
  820. fontSize: 16.5,
  821. marginRight: 10,
  822. }}
  823. />
  824. ) : (
  825. <span
  826. role="button"
  827. aria-label="enhance prompt"
  828. data-testid="enhance-prompt-button"
  829. className={`input-icon-button ${
  830. textAreaDisabled ? "disabled" : ""
  831. } codicon codicon-sparkle`}
  832. onClick={() => !textAreaDisabled && handleEnhancePrompt()}
  833. style={{ fontSize: 16.5 }}
  834. />
  835. )}
  836. </div>
  837. <span
  838. className={`input-icon-button ${
  839. shouldDisableImages ? "disabled" : ""
  840. } codicon codicon-device-camera`}
  841. onClick={() => !shouldDisableImages && onSelectImages()}
  842. style={{ fontSize: 16.5 }}
  843. />
  844. <span
  845. className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
  846. onClick={() => !textAreaDisabled && onSend()}
  847. style={{ fontSize: 15 }}
  848. />
  849. </div>
  850. </div>
  851. </div>
  852. )
  853. },
  854. )
  855. export default ChatTextArea