Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 119 additions & 3 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { cn } from "@src/lib/utils"
import { convertToMentionPath } from "@src/utils/path-mentions"
import { StandardTooltip } from "@src/components/ui"
import { useSpellCheck, SpellCheckResult } from "@src/hooks/useSpellCheck"

import Thumbnails from "../common/Thumbnails"
import { ModeSelector } from "./ModeSelector"
Expand All @@ -32,6 +33,7 @@ import ContextMenu from "./ContextMenu"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { usePromptHistory } from "./hooks/usePromptHistory"
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
import { SpellCheckSuggestions } from "./SpellCheckSuggestions"

interface ChatTextAreaProps {
inputValue: string
Expand Down Expand Up @@ -215,6 +217,18 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
const [isFocused, setIsFocused] = useState(false)

// Spell check state
const {
misspelledWords,
checkSpelling,
isSupported: isSpellCheckSupported,
} = useSpellCheck({
enabled: true,
debounceMs: 500,
})
const [selectedMisspelling, setSelectedMisspelling] = useState<SpellCheckResult | null>(null)
const [spellCheckMenuPosition, setSpellCheckMenuPosition] = useState<{ x: number; y: number } | null>(null)

// Use custom hook for prompt history navigation
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
clineMessages,
Expand Down Expand Up @@ -558,6 +572,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Reset history navigation when user types
resetOnInputChange()

// Check spelling when text changes
if (isSpellCheckSupported) {
checkSpelling(newValue)
}

const newCursorPosition = e.target.selectionStart
setCursorPosition(newCursorPosition)

Expand Down Expand Up @@ -615,7 +634,15 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setFileSearchResults([]) // Clear file search results.
}
},
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
[
setInputValue,
setSearchRequestId,
setFileSearchResults,
setSearchLoading,
resetOnInputChange,
isSpellCheckSupported,
checkSpelling,
],
)

useEffect(() => {
Expand Down Expand Up @@ -750,15 +777,35 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return match // Return unhighlighted if command is not valid
})

// Add spell check highlights
if (isSpellCheckSupported && misspelledWords.length > 0) {
// Sort misspellings by position (reverse order to maintain positions)
const sortedMisspellings = [...misspelledWords].sort((a, b) => b.start - a.start)

sortedMisspellings.forEach((misspelling) => {
const before = processedText.slice(0, misspelling.start)
const word = processedText.slice(misspelling.start, misspelling.end)
const after = processedText.slice(misspelling.end)

// Only add spell check highlight if the word isn't already highlighted
if (!word.includes("<mark")) {
processedText =
before +
`<span class="spell-check-underline" data-word="${misspelling.word}" data-start="${misspelling.start}" data-end="${misspelling.end}">${word}</span>` +
after
}
})
}

highlightLayerRef.current.innerHTML = processedText

highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}, [commands])
}, [commands, isSpellCheckSupported, misspelledWords])

useLayoutEffect(() => {
updateHighlights()
}, [inputValue, updateHighlights])
}, [inputValue, updateHighlights, misspelledWords])

const updateCursorPosition = useCallback(() => {
if (textAreaRef.current) {
Expand Down Expand Up @@ -903,6 +950,63 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[setMode],
)

// Handle spell check word click
const handleSpellCheckClick = useCallback(
(event: React.MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains("spell-check-underline")) {
const word = target.getAttribute("data-word")
const start = parseInt(target.getAttribute("data-start") || "0", 10)
const end = parseInt(target.getAttribute("data-end") || "0", 10)

const misspelling = misspelledWords.find(
(m) => m.word === word && m.start === start && m.end === end,
)

if (misspelling) {
const rect = target.getBoundingClientRect()
setSelectedMisspelling(misspelling)
setSpellCheckMenuPosition({
x: rect.left,
y: rect.bottom + 5,
})
}
}
},
[misspelledWords],
)

// Handle spell check suggestion selection
const handleSpellCheckSuggestionSelect = useCallback(
(suggestion: string) => {
if (selectedMisspelling && textAreaRef.current) {
const currentValue = inputValue
const before = currentValue.slice(0, selectedMisspelling.start)
const after = currentValue.slice(selectedMisspelling.end)
const newValue = before + suggestion + after

setInputValue(newValue)
setSelectedMisspelling(null)
setSpellCheckMenuPosition(null)

// Update cursor position
const newCursorPos = selectedMisspelling.start + suggestion.length
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.focus()
textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)

// Re-check spelling with the corrected text
if (isSpellCheckSupported) {
checkSpelling(newValue)
}
}
},
[selectedMisspelling, inputValue, setInputValue, isSpellCheckSupported, checkSpelling],
)

// Helper function to handle API config change
const handleApiConfigChange = useCallback((value: string) => {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
Expand Down Expand Up @@ -1011,6 +1115,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
style={{
color: "transparent",
}}
onClick={handleSpellCheckClick}
/>
<DynamicTextArea
ref={(el) => {
Expand Down Expand Up @@ -1268,6 +1373,17 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)}
</div>
</div>

{/* Spell check suggestions menu */}
<SpellCheckSuggestions
misspelling={selectedMisspelling}
position={spellCheckMenuPosition}
onSelect={handleSpellCheckSuggestionSelect}
onDismiss={() => {
setSelectedMisspelling(null)
setSpellCheckMenuPosition(null)
}}
/>
</div>
)
},
Expand Down
165 changes: 165 additions & 0 deletions webview-ui/src/components/chat/SpellCheckSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import { cn } from "@src/lib/utils"
import { SpellCheckResult } from "@src/hooks/useSpellCheck"

interface SpellCheckSuggestionsProps {
misspelling: SpellCheckResult | null
position: { x: number; y: number } | null
onSelect: (suggestion: string) => void
onDismiss: () => void
onIgnore?: () => void
onAddToDictionary?: () => void
}

export const SpellCheckSuggestions: React.FC<SpellCheckSuggestionsProps> = ({
misspelling,
position,
onSelect,
onDismiss,
onIgnore,
onAddToDictionary,
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const [selectedIndex, setSelectedIndex] = useState(0)

// Reset selected index when misspelling changes
useEffect(() => {
setSelectedIndex(0)
}, [misspelling])

// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onDismiss()
}
}

if (misspelling && position) {
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}
}, [misspelling, position, onDismiss])

// Handle keyboard navigation
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!misspelling) return

const totalItems = misspelling.suggestions.length + (onIgnore ? 1 : 0) + (onAddToDictionary ? 1 : 0)

switch (event.key) {
case "ArrowUp":
event.preventDefault()
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems)
break
case "ArrowDown":
event.preventDefault()
setSelectedIndex((prev) => (prev + 1) % totalItems)
break
case "Enter":
event.preventDefault()
if (selectedIndex < misspelling.suggestions.length) {
onSelect(misspelling.suggestions[selectedIndex])
} else if (selectedIndex === misspelling.suggestions.length && onIgnore) {
onIgnore()
} else if (onAddToDictionary) {
onAddToDictionary()
}
break
case "Escape":
event.preventDefault()
onDismiss()
break
}
},
[misspelling, selectedIndex, onSelect, onIgnore, onAddToDictionary, onDismiss],
)

useEffect(() => {
if (misspelling && position) {
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("keydown", handleKeyDown)
}
}
}, [misspelling, position, handleKeyDown])

if (!misspelling || !position) {
return null
}

const menuItems = [
...misspelling.suggestions.map((suggestion, index) => ({
label: suggestion,
onClick: () => onSelect(suggestion),
isSelected: selectedIndex === index,
className: "font-medium",
})),
]

if (onIgnore) {
menuItems.push({
label: "Ignore",
onClick: onIgnore,
isSelected: selectedIndex === misspelling.suggestions.length,
className: "text-vscode-descriptionForeground",
})
}

if (onAddToDictionary) {
menuItems.push({
label: "Add to dictionary",
onClick: onAddToDictionary,
isSelected: selectedIndex === misspelling.suggestions.length + (onIgnore ? 1 : 0),
className: "text-vscode-descriptionForeground",
})
}

return (
<div
ref={menuRef}
className={cn(
"absolute z-50",
"min-w-[150px] max-w-[250px]",
"bg-vscode-dropdown-background",
"border border-vscode-dropdown-border",
"rounded-md",
"shadow-lg",
"py-1",
"animate-fade-in",
)}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
role="menu"
aria-label="Spelling suggestions">
{misspelling.suggestions.length === 0 ? (
<div className="px-3 py-2 text-vscode-descriptionForeground text-sm">No suggestions available</div>
) : (
menuItems.map((item, index) => (
<button
key={index}
className={cn(
"w-full text-left px-3 py-1.5",
"text-sm",
"hover:bg-vscode-list-hoverBackground",
"focus:bg-vscode-list-focusBackground",
"focus:outline-none",
"transition-colors",
item.className,
item.isSelected && "bg-vscode-list-activeSelectionBackground",
)}
onClick={item.onClick}
onMouseEnter={() => setSelectedIndex(index)}
role="menuitem">
{item.label}
</button>
))
)}
</div>
)
}
Loading
Loading