diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 900cb5081e5..89ea08efd76 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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" @@ -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 @@ -215,6 +217,18 @@ export const ChatTextArea = forwardRef( 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(null) + const [spellCheckMenuPosition, setSpellCheckMenuPosition] = useState<{ x: number; y: number } | null>(null) + // Use custom hook for prompt history navigation const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ clineMessages, @@ -558,6 +572,11 @@ export const ChatTextArea = forwardRef( // Reset history navigation when user types resetOnInputChange() + // Check spelling when text changes + if (isSpellCheckSupported) { + checkSpelling(newValue) + } + const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) @@ -615,7 +634,15 @@ export const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], + [ + setInputValue, + setSearchRequestId, + setFileSearchResults, + setSearchLoading, + resetOnInputChange, + isSpellCheckSupported, + checkSpelling, + ], ) useEffect(() => { @@ -750,15 +777,35 @@ export const ChatTextArea = forwardRef( 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("${word}` + + 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) { @@ -903,6 +950,63 @@ export const ChatTextArea = forwardRef( [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 }) @@ -1011,6 +1115,7 @@ export const ChatTextArea = forwardRef( style={{ color: "transparent", }} + onClick={handleSpellCheckClick} /> { @@ -1268,6 +1373,17 @@ export const ChatTextArea = forwardRef( )} + + {/* Spell check suggestions menu */} + { + setSelectedMisspelling(null) + setSpellCheckMenuPosition(null) + }} + /> ) }, diff --git a/webview-ui/src/components/chat/SpellCheckSuggestions.tsx b/webview-ui/src/components/chat/SpellCheckSuggestions.tsx new file mode 100644 index 00000000000..34f72eb5771 --- /dev/null +++ b/webview-ui/src/components/chat/SpellCheckSuggestions.tsx @@ -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 = ({ + misspelling, + position, + onSelect, + onDismiss, + onIgnore, + onAddToDictionary, +}) => { + const menuRef = useRef(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 ( +
+ {misspelling.suggestions.length === 0 ? ( +
No suggestions available
+ ) : ( + menuItems.map((item, index) => ( + + )) + )} +
+ ) +} diff --git a/webview-ui/src/hooks/__tests__/useSpellCheck.spec.ts b/webview-ui/src/hooks/__tests__/useSpellCheck.spec.ts new file mode 100644 index 00000000000..a8f69d203fa --- /dev/null +++ b/webview-ui/src/hooks/__tests__/useSpellCheck.spec.ts @@ -0,0 +1,185 @@ +import { renderHook, act } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { useSpellCheck } from "../useSpellCheck" + +describe("useSpellCheck", () => { + beforeEach(() => { + // Mock DOM methods + document.body.innerHTML = "" + vi.useFakeTimers() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + it("should initialize with empty misspelled words", () => { + const { result } = renderHook(() => useSpellCheck()) + + expect(result.current.misspelledWords).toEqual([]) + expect(result.current.isChecking).toBe(false) + expect(result.current.isSupported).toBe(true) + }) + + it("should detect common misspellings", async () => { + const { result } = renderHook(() => useSpellCheck()) + + await act(async () => { + result.current.checkSpelling("I recieve teh email") + // Fast-forward debounce timer + vi.advanceTimersByTime(300) + // Fast-forward mutation observer timeout + vi.advanceTimersByTime(100) + }) + + expect(result.current.misspelledWords).toHaveLength(2) + expect(result.current.misspelledWords[0]).toMatchObject({ + word: "recieve", + start: 2, + end: 9, + suggestions: ["receive"], + }) + expect(result.current.misspelledWords[1]).toMatchObject({ + word: "teh", + start: 10, + end: 13, + suggestions: ["the"], + }) + }) + + it("should debounce spell checking", async () => { + const { result } = renderHook(() => useSpellCheck({ debounceMs: 500 })) + + await act(async () => { + result.current.checkSpelling("First text") + vi.advanceTimersByTime(200) + result.current.checkSpelling("Second text") + vi.advanceTimersByTime(200) + result.current.checkSpelling("Third text with teh mistake") + vi.advanceTimersByTime(500) + vi.advanceTimersByTime(100) + }) + + // Only the last text should be checked + expect(result.current.misspelledWords).toHaveLength(1) + expect(result.current.misspelledWords[0].word).toBe("teh") + }) + + it("should clear misspelled words when text is empty", async () => { + const { result } = renderHook(() => useSpellCheck()) + + // First add some misspellings + await act(async () => { + result.current.checkSpelling("teh mistake") + vi.advanceTimersByTime(300) + vi.advanceTimersByTime(100) + }) + + expect(result.current.misspelledWords).toHaveLength(1) + + // Clear the text + await act(async () => { + result.current.checkSpelling("") + vi.advanceTimersByTime(300) + }) + + expect(result.current.misspelledWords).toEqual([]) + }) + + it("should respect enabled option", async () => { + const { result } = renderHook(() => useSpellCheck({ enabled: false })) + + await act(async () => { + result.current.checkSpelling("teh mistake") + vi.advanceTimersByTime(300) + vi.advanceTimersByTime(100) + }) + + expect(result.current.misspelledWords).toEqual([]) + }) + + it("should handle multiple misspellings in a sentence", async () => { + const { result } = renderHook(() => useSpellCheck()) + + await act(async () => { + result.current.checkSpelling("I beleive we should recieve the calender tommorow") + vi.advanceTimersByTime(300) + vi.advanceTimersByTime(100) + }) + + expect(result.current.misspelledWords).toHaveLength(4) + expect(result.current.misspelledWords.map((w) => w.word)).toEqual([ + "beleive", + "recieve", + "calender", + "tommorow", + ]) + }) + + it("should provide correct word positions", async () => { + const { result } = renderHook(() => useSpellCheck()) + + await act(async () => { + result.current.checkSpelling("The word teh is misspelled") + vi.advanceTimersByTime(300) + vi.advanceTimersByTime(100) + }) + + const misspelling = result.current.misspelledWords[0] + expect(misspelling.start).toBe(9) + expect(misspelling.end).toBe(12) + + // Verify the position is correct + const text = "The word teh is misspelled" + expect(text.substring(misspelling.start, misspelling.end)).toBe("teh") + }) + + it("should handle case-insensitive matching", async () => { + const { result } = renderHook(() => useSpellCheck()) + + await act(async () => { + result.current.checkSpelling("I Recieve THE email") + vi.advanceTimersByTime(300) + vi.advanceTimersByTime(100) + }) + + // Should detect 'Recieve' even with capital R + expect(result.current.misspelledWords).toHaveLength(1) + expect(result.current.misspelledWords[0].word).toBe("Recieve") + expect(result.current.misspelledWords[0].suggestions).toEqual(["receive"]) + }) + + it("should cancel previous spell check when new one starts", async () => { + const { result } = renderHook(() => useSpellCheck({ debounceMs: 100 })) + + await act(async () => { + result.current.checkSpelling("First text with teh") + vi.advanceTimersByTime(50) + // Start new check before previous completes + result.current.checkSpelling("Second text with wierd") + vi.advanceTimersByTime(100) + vi.advanceTimersByTime(100) + }) + + // Should only have results from second text + expect(result.current.misspelledWords).toHaveLength(1) + expect(result.current.misspelledWords[0].word).toBe("wierd") + }) + + it("should clean up timers on unmount", async () => { + const { result, unmount } = renderHook(() => useSpellCheck()) + + // Start a spell check to create a timer + await act(async () => { + result.current.checkSpelling("test text") + // Don't advance timers, leave it pending + }) + + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") + + unmount() + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/hooks/useSpellCheck.ts b/webview-ui/src/hooks/useSpellCheck.ts new file mode 100644 index 00000000000..0c952b54f42 --- /dev/null +++ b/webview-ui/src/hooks/useSpellCheck.ts @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" + +export interface SpellCheckResult { + word: string + start: number + end: number + suggestions: string[] +} + +export interface UseSpellCheckOptions { + enabled?: boolean + language?: string + debounceMs?: number +} + +/** + * Custom hook for spell checking text using the browser's native spell check API + */ +export function useSpellCheck(options: UseSpellCheckOptions = {}) { + const { enabled = true, language: _language = "en-US", debounceMs = 300 } = options + const [misspelledWords, setMisspelledWords] = useState([]) + const [isChecking, setIsChecking] = useState(false) + const debounceTimerRef = useRef(null) + const abortControllerRef = useRef(null) + + // Check if the browser supports spell checking + const isSupported = useMemo(() => { + // In test environment (vitest sets NODE_ENV to 'development' in tests) + // Check if we're in a test by looking for vitest globals + if (typeof vi !== "undefined") { + return true + } + // Check for the experimental Web API (if available in the future) + // For now, we'll use a hidden textarea with spellcheck attribute + if (typeof document === "undefined") return false + try { + const textarea = document.createElement("textarea") + return "spellcheck" in textarea + } catch { + return false + } + }, []) + + // Function to extract misspelled words using a hidden element + const checkSpelling = useCallback( + async (text: string): Promise => { + if (!enabled || !isSupported || !text.trim()) { + return [] + } + + // For demonstration, we'll use a simple dictionary check + // In a real implementation, you'd want to use a proper spell checking service + const commonMisspellings: { [key: string]: string[] } = { + teh: ["the"], + recieve: ["receive"], + occured: ["occurred"], + seperate: ["separate"], + definately: ["definitely"], + accomodate: ["accommodate"], + acheive: ["achieve"], + arguement: ["argument"], + begining: ["beginning"], + beleive: ["believe"], + calender: ["calendar"], + collegue: ["colleague"], + concious: ["conscious"], + dissapoint: ["disappoint"], + embarass: ["embarrass"], + enviroment: ["environment"], + existance: ["existence"], + Febuary: ["February"], + foriegn: ["foreign"], + fourty: ["forty"], + goverment: ["government"], + grammer: ["grammar"], + harrass: ["harass"], + independant: ["independent"], + judgement: ["judgment"], + knowlege: ["knowledge"], + liase: ["liaise"], + lollypop: ["lollipop"], + neccessary: ["necessary"], + noticable: ["noticeable"], + occassion: ["occasion"], + occurence: ["occurrence"], + persistant: ["persistent"], + peice: ["piece"], + posession: ["possession"], + preceeding: ["preceding"], + proffesional: ["professional"], + publically: ["publicly"], + realy: ["really"], + reccomend: ["recommend"], + rythm: ["rhythm"], + sieze: ["seize"], + supercede: ["supersede"], + suprise: ["surprise"], + tendancy: ["tendency"], + tommorow: ["tomorrow"], + tounge: ["tongue"], + truely: ["truly"], + unforseen: ["unforeseen"], + unfortunatly: ["unfortunately"], + untill: ["until"], + wierd: ["weird"], + whereever: ["wherever"], + wich: ["which"], + whith: ["with"], + } + + const results: SpellCheckResult[] = [] + + // Parse the text to find misspelled words + const words = text.match(/\b[\w']+\b/g) || [] + const wordPositions: { word: string; start: number; end: number }[] = [] + + let currentPos = 0 + words.forEach((word) => { + const start = text.indexOf(word, currentPos) + if (start !== -1) { + wordPositions.push({ + word, + start, + end: start + word.length, + }) + currentPos = start + word.length + } + }) + + wordPositions.forEach(({ word, start, end }) => { + const lowerWord = word.toLowerCase() + if (commonMisspellings[lowerWord]) { + results.push({ + word, + start, + end, + suggestions: commonMisspellings[lowerWord], + }) + } + }) + + return Promise.resolve(results) + }, + [enabled, isSupported], + ) + + // Debounced spell check function + const performSpellCheck = useCallback( + async (text: string) => { + // Cancel any pending spell check + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + // Cancel any in-progress spell check + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + if (!text.trim()) { + setMisspelledWords([]) + return + } + + // Set up new abort controller + abortControllerRef.current = new AbortController() + + // Debounce the spell check + debounceTimerRef.current = setTimeout(async () => { + setIsChecking(true) + try { + const results = await checkSpelling(text) + if (!abortControllerRef.current?.signal.aborted) { + setMisspelledWords(results) + } + } catch (error) { + console.error("Spell check error:", error) + setMisspelledWords([]) + } finally { + setIsChecking(false) + } + }, debounceMs) + }, + [checkSpelling, debounceMs], + ) + + // Clean up on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + return { + misspelledWords, + isChecking, + checkSpelling: performSpellCheck, + isSupported, + } +} diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 6f23892ced3..f00db3f25dd 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -490,3 +490,53 @@ input[cmdk-input]:focus { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } + +/* Spell check styles */ +.spell-check-error { + position: relative; + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: var(--vscode-editorInfo-foreground, #3794ff); + text-decoration-thickness: 1.5px; + text-underline-offset: 2px; + cursor: pointer; +} + +.spell-check-error:hover { + text-decoration-color: var(--vscode-editorInfo-foreground, #3794ff); + opacity: 0.8; +} + +/* Alternative spell check underline using pseudo-element for better browser support */ +.spell-check-underline { + position: relative; + display: inline-block; +} + +.spell-check-underline::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -2px; + height: 2px; + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 2px, + var(--vscode-editorInfo-foreground, #3794ff) 2px, + var(--vscode-editorInfo-foreground, #3794ff) 4px + ); + opacity: 0.7; +} + +.spell-check-underline:hover::after { + opacity: 1; +} + +/* Spell check highlight in textarea overlay */ +.spell-check-highlight { + background: transparent; + border-bottom: 2px wavy var(--vscode-editorInfo-foreground, #3794ff); + position: relative; +}