refactor: switch to native browser spell check

- Remove custom spell check implementation (spellCheck.ts and tests)
- Use native spellcheck attribute on textarea element
- Remove custom spell check CSS styles
- Update translation to reflect native browser spell check usage
- Simplify ChatTextArea component by removing spell check logic

As requested by @hannesrudolph, this implementation now uses the system's native spell check service instead of a custom dictionary-based approach.
This commit is contained in:
Roo Code 2025-07-26 22:46:52 +00:00
parent 8814688861
commit 2142c198d9
5 changed files with 2 additions and 681 deletions

View file

@ -11,7 +11,6 @@ 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,
@ -183,8 +182,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const [spellCheckResults, setSpellCheckResults] = useState<SpellCheckResult[]>([])
const spellCheckLayerRef = useRef<HTMLDivElement>(null)
// Use custom hook for prompt history navigation
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
@ -226,31 +223,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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(() => {
@ -709,52 +681,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}, [])
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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
// Add the misspelled word with highlighting
const misspelledWord = text.slice(result.startIndex, result.endIndex)
html += `<span class="spell-check-error">${misspelledWord.replace(
/[<>&]/g,
(c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c,
)}</span>`
lastIndex = result.endIndex
})
// Add remaining text
const remainingText = text.slice(lastIndex)
html += remainingText
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[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<HTMLTextAreaElement>) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
@ -1125,30 +1051,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
color: "transparent",
}}
/>
{isSpellCheckEnabled && (
<div
ref={spellCheckLayerRef}
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"py-2",
"px-[9px]",
"z-[5]",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
)}
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
@ -1181,6 +1083,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
minRows={3}
maxRows={15}
autoFocus={true}
spellCheck={isSpellCheckEnabled}
className={cn(
"w-full",
"text-vscode-input-foreground",
@ -1213,7 +1116,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)}
onScroll={() => {
updateHighlights()
updateSpellCheckHighlights()
}}
/>

View file

@ -651,7 +651,7 @@
},
"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."
"description": "When enabled, uses the browser's native spell check to underline misspelled words in the chat input. This helps catch typos before sending messages to Roo."
}
},
"promptCaching": {

View file

@ -386,14 +386,6 @@ 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
*/

View file

@ -1,142 +0,0 @@
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")
})
})
})

View file

@ -1,431 +0,0 @@
/**
* 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 = /(?<![@/])\b[\w']+\b/g
let match
while ((match = wordRegex.exec(text)) !== null) {
// Double check that this word is not part of a mention or command
const charBefore = text[match.index - 1]
if (charBefore !== "@" && charBefore !== "/") {
words.push({
word: match[0],
start: match.index,
end: match.index + match[0].length,
})
}
}
return words
}
/**
* Perform spell check on the given text
* Returns an array of potentially misspelled words with their positions
*/
export const checkSpelling = async (text: string): Promise<SpellCheckResult[]> => {
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 = <T extends (...args: any[]) => any>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}