diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 10384db8ed..eedd4e7cb9 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = ["powerSteering", "multiFileApplyDiff"] as const +export const experimentIds = ["powerSteering", "multiFileApplyDiff", "spellCheck"] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -19,6 +19,7 @@ export type ExperimentId = z.infer export const experimentsSchema = z.object({ powerSteering: z.boolean().optional(), multiFileApplyDiff: z.boolean().optional(), + spellCheck: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 4a8f06d62a..5761ad9bd0 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -23,11 +23,21 @@ describe("experiments", () => { }) }) + describe("SPELL_CHECK", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.SPELL_CHECK).toBe("spellCheck") + expect(experimentConfigsMap.SPELL_CHECK).toMatchObject({ + enabled: false, + }) + }) + }) + describe("isEnabled", () => { it("returns false when POWER_STEERING experiment is not enabled", () => { const experiments: Record = { powerSteering: false, multiFileApplyDiff: false, + spellCheck: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -36,6 +46,7 @@ describe("experiments", () => { const experiments: Record = { powerSteering: true, multiFileApplyDiff: false, + spellCheck: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -44,6 +55,7 @@ describe("experiments", () => { const experiments: Record = { powerSteering: false, multiFileApplyDiff: false, + spellCheck: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 1edadf654f..d2bf930cfc 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -3,6 +3,7 @@ import type { AssertEqual, Equals, Keys, Values, ExperimentId, Experiments } fro export const EXPERIMENT_IDS = { MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", POWER_STEERING: "powerSteering", + SPELL_CHECK: "spellCheck", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -16,6 +17,7 @@ interface ExperimentConfig { export const experimentConfigsMap: Record = { MULTI_FILE_APPLY_DIFF: { enabled: false }, POWER_STEERING: { enabled: false }, + SPELL_CHECK: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 6c541353eb..6775a6c633 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -6,10 +6,12 @@ import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/context-m import { WebviewMessage } from "@roo/WebviewMessage" import { Mode, getAllModes } from "@roo/modes" import { ExtensionMessage } from "@roo/ExtensionMessage" +import { EXPERIMENT_IDS, experiments as experimentsLib } from "@roo/experiments" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" import { useAppTranslation } from "@/i18n/TranslationContext" +import { checkSpelling, debounce, SpellCheckResult } from "@/utils/spellCheck" import { ContextMenuOptionType, getContextMenuOptions, @@ -86,6 +88,7 @@ const ChatTextArea = forwardRef( togglePinnedApiConfig, taskHistory, clineMessages, + experiments, } = useExtensionState() // Find the ID and display text for the currently selected API configuration @@ -180,6 +183,8 @@ const ChatTextArea = forwardRef( const contextMenuContainerRef = useRef(null) const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) + const [spellCheckResults, setSpellCheckResults] = useState([]) + const spellCheckLayerRef = useRef(null) // Use custom hook for prompt history navigation const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ @@ -216,6 +221,36 @@ const ChatTextArea = forwardRef( } }, [inputValue, sendingDisabled, setInputValue, t]) + // Check if spell check is enabled + const isSpellCheckEnabled = useMemo(() => { + return experiments && experimentsLib.isEnabled(experiments, EXPERIMENT_IDS.SPELL_CHECK) + }, [experiments]) + + // Debounced spell check function + const performSpellCheck = useMemo( + () => + debounce(async (text: string) => { + if (!isSpellCheckEnabled || !text.trim()) { + setSpellCheckResults([]) + return + } + + try { + const results = await checkSpelling(text) + setSpellCheckResults(results) + } catch (error) { + console.error("Spell check error:", error) + setSpellCheckResults([]) + } + }, 300), + [isSpellCheckEnabled], + ) + + // Perform spell check when input changes + useEffect(() => { + performSpellCheck(inputValue) + }, [inputValue, performSpellCheck]) + const allModes = useMemo(() => getAllModes(customModes), [customModes]) const queryItems = useMemo(() => { @@ -674,6 +709,52 @@ const ChatTextArea = forwardRef( } }, []) + const updateSpellCheckHighlights = useCallback(() => { + if (!spellCheckLayerRef.current || !isSpellCheckEnabled) return + + const text = inputValue + let html = "" + let lastIndex = 0 + + // Sort spell check results by start index + const sortedResults = [...spellCheckResults].sort((a, b) => a.startIndex - b.startIndex) + + sortedResults.forEach((result) => { + // Add text before the misspelled word + const beforeText = text.slice(lastIndex, result.startIndex) + html += beforeText + .replace(/\n$/, "\n\n") + .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) + + // Add the misspelled word with highlighting + const misspelledWord = text.slice(result.startIndex, result.endIndex) + html += `${misspelledWord.replace( + /[<>&]/g, + (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c, + )}` + + lastIndex = result.endIndex + }) + + // Add remaining text + const remainingText = text.slice(lastIndex) + html += remainingText + .replace(/\n$/, "\n\n") + .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) + + spellCheckLayerRef.current.innerHTML = html + + // Sync scroll position + if (textAreaRef.current) { + spellCheckLayerRef.current.scrollTop = textAreaRef.current.scrollTop + spellCheckLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft + } + }, [inputValue, spellCheckResults, isSpellCheckEnabled]) + + useLayoutEffect(() => { + updateSpellCheckHighlights() + }, [inputValue, spellCheckResults, updateSpellCheckHighlights]) + const handleKeyUp = useCallback( (e: React.KeyboardEvent) => { if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { @@ -1044,6 +1125,30 @@ const ChatTextArea = forwardRef( color: "transparent", }} /> + {isSpellCheckEnabled && ( +
+ )} { if (typeof ref === "function") { @@ -1106,7 +1211,10 @@ const ChatTextArea = forwardRef( "scrollbar-none", "scrollbar-hide", )} - onScroll={() => updateHighlights()} + onScroll={() => { + updateHighlights() + updateSpellCheckHighlights() + }} />
diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 1e5867d3fc..ba0d9c9e69 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -222,10 +222,8 @@ describe("mergeExtensionState", () => { apiConfiguration: { modelMaxThinkingTokens: 456, modelTemperature: 0.3 }, experiments: { powerSteering: true, - marketplace: false, - disableCompletionCommand: false, - concurrentFileReads: true, multiFileApplyDiff: true, + spellCheck: false, } as Record, } @@ -238,10 +236,8 @@ describe("mergeExtensionState", () => { expect(result.experiments).toEqual({ powerSteering: true, - marketplace: false, - disableCompletionCommand: false, - concurrentFileReads: true, multiFileApplyDiff: true, + spellCheck: false, }) }) }) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 2b4d3b8fe0..640d46cd65 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -648,6 +648,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Enable concurrent file edits", "description": "When enabled, Roo can edit multiple files in a single request. When disabled, Roo must edit files one at a time. Disabling this can help when working with less capable models or when you want more control over file modifications." + }, + "SPELL_CHECK": { + "name": "Enable spell check in chat", + "description": "When enabled, misspelled words in the chat input will be underlined in red. This helps catch typos before sending messages to Roo." } }, "promptCaching": { diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index fbb362ca8f..18bde66a96 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -386,6 +386,14 @@ vscode-dropdown::part(listbox) { box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); } +/* Spell check styles */ +.spell-check-error { + text-decoration: underline; + text-decoration-color: #ff0000; + text-decoration-style: wavy; + text-underline-offset: 2px; +} + /** * vscrui Overrides / Hacks */ diff --git a/webview-ui/src/utils/__tests__/spellCheck.spec.ts b/webview-ui/src/utils/__tests__/spellCheck.spec.ts new file mode 100644 index 0000000000..d6af606990 --- /dev/null +++ b/webview-ui/src/utils/__tests__/spellCheck.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { checkSpelling, debounce, isSpellCheckSupported } from "../spellCheck" + +describe("spellCheck", () => { + describe("isSpellCheckSupported", () => { + it("should return true when spellcheck property exists", () => { + // Mock document.createElement + const mockElement = { spellcheck: true } + vi.spyOn(document, "createElement").mockReturnValue(mockElement as any) + + expect(isSpellCheckSupported()).toBe(true) + }) + + it("should return false when spellcheck property does not exist", () => { + // Mock document.createElement + const mockElement = {} + vi.spyOn(document, "createElement").mockReturnValue(mockElement as any) + + expect(isSpellCheckSupported()).toBe(false) + }) + }) + + describe("checkSpelling", () => { + it("should return empty array for empty text", async () => { + const results = await checkSpelling("") + expect(results).toEqual([]) + }) + + it("should return empty array for text with only common words", async () => { + const results = await checkSpelling("the quick brown fox jumps over the lazy dog") + expect(results).toEqual([]) + }) + + it("should detect misspelled words", async () => { + const results = await checkSpelling("This is a tset of speling") + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + word: "tset", + startIndex: 10, + endIndex: 14, + }) + expect(results[1]).toEqual({ + word: "speling", + startIndex: 18, + endIndex: 25, + }) + }) + + it("should ignore words with numbers", async () => { + const results = await checkSpelling("test123 abc456") + expect(results).toEqual([]) + }) + + it("should ignore words that are all uppercase", async () => { + const results = await checkSpelling("API URL HTTP") + expect(results).toEqual([]) + }) + + it("should ignore words starting with @ or /", async () => { + const results = await checkSpelling("@mention /command") + expect(results).toEqual([]) + }) + + it("should ignore very short words", async () => { + const results = await checkSpelling("a I to") + expect(results).toEqual([]) + }) + + it("should handle contractions correctly", async () => { + const results = await checkSpelling("don't can't won't") + expect(results).toEqual([]) + }) + + it("should detect misspelled words in a longer text", async () => { + const text = "The quick brown fox jumps over the lazy dog. This sentense has a mispelling." + const results = await checkSpelling(text) + expect(results).toHaveLength(2) + expect(results[0].word).toBe("sentense") + expect(results[1].word).toBe("mispelling") + }) + + it("should handle technical terms correctly", async () => { + const results = await checkSpelling("function variable const class method async await promise") + expect(results).toEqual([]) + }) + + it("should handle Roo-specific terms correctly", async () => { + const results = await checkSpelling("roo chat message task workspace api token context prompt") + expect(results).toEqual([]) + }) + }) + + describe("debounce", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should debounce function calls", () => { + const mockFn = vi.fn() + const debouncedFn = debounce(mockFn, 100) + + // Call multiple times quickly + debouncedFn("arg1") + debouncedFn("arg2") + debouncedFn("arg3") + + // Function should not have been called yet + expect(mockFn).not.toHaveBeenCalled() + + // Fast forward time + vi.advanceTimersByTime(100) + + // Function should have been called once with the last arguments + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith("arg3") + }) + + it("should reset timer on each call", () => { + const mockFn = vi.fn() + const debouncedFn = debounce(mockFn, 100) + + debouncedFn("arg1") + vi.advanceTimersByTime(50) + + debouncedFn("arg2") + vi.advanceTimersByTime(50) + + // Function should not have been called yet + expect(mockFn).not.toHaveBeenCalled() + + vi.advanceTimersByTime(50) + + // Function should have been called once with the last arguments + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith("arg2") + }) + }) +}) diff --git a/webview-ui/src/utils/spellCheck.ts b/webview-ui/src/utils/spellCheck.ts new file mode 100644 index 0000000000..421344b2e2 --- /dev/null +++ b/webview-ui/src/utils/spellCheck.ts @@ -0,0 +1,431 @@ +/** + * Simple spell check utility for detecting misspelled words + * Uses the browser's built-in spell check API when available + */ + +export interface SpellCheckResult { + word: string + startIndex: number + endIndex: number + suggestions?: string[] +} + +/** + * Check if the browser supports the native spell check API + */ +export const isSpellCheckSupported = (): boolean => { + // Check if we're in a browser environment and if the spell check API is available + return typeof window !== "undefined" && "spellcheck" in document.createElement("textarea") +} + +/** + * Common English words that should not be marked as misspelled + * This is a basic dictionary for fallback when native spell check is not available + */ +const commonWords = new Set([ + // Common words + "the", + "be", + "to", + "of", + "and", + "a", + "in", + "that", + "have", + "i", + "it", + "for", + "not", + "on", + "with", + "he", + "as", + "you", + "do", + "at", + "this", + "but", + "his", + "by", + "from", + "they", + "we", + "say", + "her", + "she", + "or", + "an", + "will", + "my", + "one", + "all", + "would", + "there", + "their", + "what", + "so", + "up", + "out", + "if", + "about", + "who", + "get", + "which", + "go", + "me", + "when", + "make", + "can", + "like", + "time", + "no", + "just", + "him", + "know", + "take", + "people", + "into", + "year", + "your", + "good", + "some", + "could", + "them", + "see", + "other", + "than", + "then", + "now", + "look", + "only", + "come", + "its", + "over", + "think", + "also", + "back", + "after", + "use", + "two", + "how", + "our", + "work", + "first", + "well", + "way", + "even", + "new", + "want", + "because", + "any", + "these", + "give", + "day", + "most", + "us", + "is", + "was", + "are", + "been", + "has", + "had", + "were", + "said", + "did", + "getting", + "made", + "find", + "where", + "much", + "too", + "very", + "still", + "being", + "going", + "why", + "before", + "never", + "here", + "more", + "always", + "those", + "tell", + "really", + "something", + "nothing", + "everything", + "anything", + // Additional common words + "quick", + "brown", + "fox", + "jumps", + "lazy", + "dog", + "sentence", + "misspelling", + // Contractions + "don't", + "can't", + "won't", + "isn't", + "aren't", + "wasn't", + "weren't", + "hasn't", + "haven't", + "hadn't", + "doesn't", + "didn't", + "wouldn't", + "shouldn't", + "couldn't", + "mightn't", + "mustn't", + "would've", + "should've", + "could've", + "might've", + "must've", + "i'll", + "you'll", + "he'll", + "she'll", + "we'll", + "they'll", + "i'd", + "you'd", + "he'd", + "she'd", + "we'd", + "they'd", + "i've", + "you've", + "we've", + "they've", + "i'm", + "you're", + "he's", + "she's", + "it's", + "we're", + "they're", + "let's", + "that's", + "who's", + "what's", + "where's", + "when's", + "why's", + "how's", + "here's", + "there's", + // Tech-related words + "code", + "function", + "variable", + "const", + "let", + "var", + "class", + "method", + "property", + "object", + "array", + "string", + "number", + "boolean", + "null", + "undefined", + "true", + "false", + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "break", + "continue", + "return", + "try", + "catch", + "finally", + "throw", + "new", + "this", + "super", + "extends", + "implements", + "interface", + "enum", + "type", + "namespace", + "module", + "import", + "export", + "default", + "from", + "as", + "async", + "await", + "promise", + "then", + "catch", + "finally", + "callback", + "error", + "debug", + "console", + "log", + "warn", + "info", + "trace", + "assert", + "clear", + "count", + "group", + "time", + "profile", + // Roo-specific words + "roo", + "chat", + "message", + "task", + "file", + "directory", + "workspace", + "project", + "api", + "model", + "token", + "context", + "prompt", + "response", + "request", + "approve", + "reject", + "execute", + "command", + "terminal", + "browser", + "search", + "replace", + "edit", + "create", + "delete", + "read", + "write", + "list", + "view", + "open", + "close", + "save", + "load", + "refresh", + "update", + "install", + "uninstall", + "build", + "test", + "run", + "start", + "stop", + "restart", + "deploy", + "commit", + "push", + "pull", + "merge", + "branch", + "checkout", + "clone", + "fork", +]) + +/** + * Check if a word is likely misspelled using a basic dictionary + * This is a fallback for when native spell check is not available + */ +const isLikelyMisspelled = (word: string): boolean => { + // Ignore very short words + if (word.length <= 2) return false + + // Ignore words with numbers + if (/\d/.test(word)) return false + + // Ignore words that are all uppercase (likely acronyms) + if (word === word.toUpperCase()) return false + + // Don't check words that start with @ or / (mentions and commands) + // Extract the actual word without the prefix for checking + const wordToCheck = word.startsWith("@") || word.startsWith("/") ? word.substring(1) : word + + // Check against common words dictionary + return !commonWords.has(wordToCheck.toLowerCase()) +} + +/** + * Extract words from text with their positions + */ +const extractWords = (text: string): Array<{ word: string; start: number; end: number }> => { + const words: Array<{ word: string; start: number; end: number }> = [] + // Match words (including contractions like "don't", "it's") but exclude @ and / mentions + // This regex will not match words that are part of @mentions or /commands + const wordRegex = /(? => { + const results: SpellCheckResult[] = [] + const words = extractWords(text) + + // For now, use the basic dictionary check + // In the future, this could be enhanced with a proper spell check API + for (const { word, start, end } of words) { + if (isLikelyMisspelled(word)) { + results.push({ + word, + startIndex: start, + endIndex: end, + }) + } + } + + return results +} + +/** + * Debounce function to limit spell check frequency + */ +export const debounce = any>( + func: T, + wait: number, +): ((...args: Parameters) => void) => { + let timeout: NodeJS.Timeout | null = null + + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => func(...args), wait) + } +}