Skip to content

Commit 8814688

Browse files
committed
feat: add spell check feature to Roo Chat
- Add experimental spell check feature flag - Implement spell check utility with basic dictionary - Integrate spell check highlighting in ChatTextArea component - Add CSS styles for red wavy underlines on misspelled words - Add comprehensive tests for spell check functionality - Add i18n translations for the feature This feature helps users identify misspelled words in the chat interface by highlighting them with red wavy underlines, similar to standard text editors.
1 parent 478869e commit 8814688

File tree

9 files changed

+712
-8
lines changed

9 files changed

+712
-8
lines changed

packages/types/src/experiment.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js"
66
* ExperimentId
77
*/
88

9-
export const experimentIds = ["powerSteering", "multiFileApplyDiff"] as const
9+
export const experimentIds = ["powerSteering", "multiFileApplyDiff", "spellCheck"] as const
1010

1111
export const experimentIdsSchema = z.enum(experimentIds)
1212

@@ -19,6 +19,7 @@ export type ExperimentId = z.infer<typeof experimentIdsSchema>
1919
export const experimentsSchema = z.object({
2020
powerSteering: z.boolean().optional(),
2121
multiFileApplyDiff: z.boolean().optional(),
22+
spellCheck: z.boolean().optional(),
2223
})
2324

2425
export type Experiments = z.infer<typeof experimentsSchema>

src/shared/__tests__/experiments.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,21 @@ describe("experiments", () => {
2323
})
2424
})
2525

26+
describe("SPELL_CHECK", () => {
27+
it("is configured correctly", () => {
28+
expect(EXPERIMENT_IDS.SPELL_CHECK).toBe("spellCheck")
29+
expect(experimentConfigsMap.SPELL_CHECK).toMatchObject({
30+
enabled: false,
31+
})
32+
})
33+
})
34+
2635
describe("isEnabled", () => {
2736
it("returns false when POWER_STEERING experiment is not enabled", () => {
2837
const experiments: Record<ExperimentId, boolean> = {
2938
powerSteering: false,
3039
multiFileApplyDiff: false,
40+
spellCheck: false,
3141
}
3242
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
3343
})
@@ -36,6 +46,7 @@ describe("experiments", () => {
3646
const experiments: Record<ExperimentId, boolean> = {
3747
powerSteering: true,
3848
multiFileApplyDiff: false,
49+
spellCheck: false,
3950
}
4051
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true)
4152
})
@@ -44,6 +55,7 @@ describe("experiments", () => {
4455
const experiments: Record<ExperimentId, boolean> = {
4556
powerSteering: false,
4657
multiFileApplyDiff: false,
58+
spellCheck: false,
4759
}
4860
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
4961
})

src/shared/experiments.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AssertEqual, Equals, Keys, Values, ExperimentId, Experiments } fro
33
export const EXPERIMENT_IDS = {
44
MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff",
55
POWER_STEERING: "powerSteering",
6+
SPELL_CHECK: "spellCheck",
67
} as const satisfies Record<string, ExperimentId>
78

89
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
@@ -16,6 +17,7 @@ interface ExperimentConfig {
1617
export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
1718
MULTI_FILE_APPLY_DIFF: { enabled: false },
1819
POWER_STEERING: { enabled: false },
20+
SPELL_CHECK: { enabled: false },
1921
}
2022

2123
export const experimentDefault = Object.fromEntries(

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/context-m
66
import { WebviewMessage } from "@roo/WebviewMessage"
77
import { Mode, getAllModes } from "@roo/modes"
88
import { ExtensionMessage } from "@roo/ExtensionMessage"
9+
import { EXPERIMENT_IDS, experiments as experimentsLib } from "@roo/experiments"
910

1011
import { vscode } from "@/utils/vscode"
1112
import { useExtensionState } from "@/context/ExtensionStateContext"
1213
import { useAppTranslation } from "@/i18n/TranslationContext"
14+
import { checkSpelling, debounce, SpellCheckResult } from "@/utils/spellCheck"
1315
import {
1416
ContextMenuOptionType,
1517
getContextMenuOptions,
@@ -86,6 +88,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
8688
togglePinnedApiConfig,
8789
taskHistory,
8890
clineMessages,
91+
experiments,
8992
} = useExtensionState()
9093

9194
// Find the ID and display text for the currently selected API configuration
@@ -180,6 +183,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
180183
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
181184
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
182185
const [isFocused, setIsFocused] = useState(false)
186+
const [spellCheckResults, setSpellCheckResults] = useState<SpellCheckResult[]>([])
187+
const spellCheckLayerRef = useRef<HTMLDivElement>(null)
183188

184189
// Use custom hook for prompt history navigation
185190
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
@@ -216,6 +221,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
216221
}
217222
}, [inputValue, sendingDisabled, setInputValue, t])
218223

224+
// Check if spell check is enabled
225+
const isSpellCheckEnabled = useMemo(() => {
226+
return experiments && experimentsLib.isEnabled(experiments, EXPERIMENT_IDS.SPELL_CHECK)
227+
}, [experiments])
228+
229+
// Debounced spell check function
230+
const performSpellCheck = useMemo(
231+
() =>
232+
debounce(async (text: string) => {
233+
if (!isSpellCheckEnabled || !text.trim()) {
234+
setSpellCheckResults([])
235+
return
236+
}
237+
238+
try {
239+
const results = await checkSpelling(text)
240+
setSpellCheckResults(results)
241+
} catch (error) {
242+
console.error("Spell check error:", error)
243+
setSpellCheckResults([])
244+
}
245+
}, 300),
246+
[isSpellCheckEnabled],
247+
)
248+
249+
// Perform spell check when input changes
250+
useEffect(() => {
251+
performSpellCheck(inputValue)
252+
}, [inputValue, performSpellCheck])
253+
219254
const allModes = useMemo(() => getAllModes(customModes), [customModes])
220255

221256
const queryItems = useMemo(() => {
@@ -674,6 +709,52 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
674709
}
675710
}, [])
676711

712+
const updateSpellCheckHighlights = useCallback(() => {
713+
if (!spellCheckLayerRef.current || !isSpellCheckEnabled) return
714+
715+
const text = inputValue
716+
let html = ""
717+
let lastIndex = 0
718+
719+
// Sort spell check results by start index
720+
const sortedResults = [...spellCheckResults].sort((a, b) => a.startIndex - b.startIndex)
721+
722+
sortedResults.forEach((result) => {
723+
// Add text before the misspelled word
724+
const beforeText = text.slice(lastIndex, result.startIndex)
725+
html += beforeText
726+
.replace(/\n$/, "\n\n")
727+
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
728+
729+
// Add the misspelled word with highlighting
730+
const misspelledWord = text.slice(result.startIndex, result.endIndex)
731+
html += `<span class="spell-check-error">${misspelledWord.replace(
732+
/[<>&]/g,
733+
(c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c,
734+
)}</span>`
735+
736+
lastIndex = result.endIndex
737+
})
738+
739+
// Add remaining text
740+
const remainingText = text.slice(lastIndex)
741+
html += remainingText
742+
.replace(/\n$/, "\n\n")
743+
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
744+
745+
spellCheckLayerRef.current.innerHTML = html
746+
747+
// Sync scroll position
748+
if (textAreaRef.current) {
749+
spellCheckLayerRef.current.scrollTop = textAreaRef.current.scrollTop
750+
spellCheckLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
751+
}
752+
}, [inputValue, spellCheckResults, isSpellCheckEnabled])
753+
754+
useLayoutEffect(() => {
755+
updateSpellCheckHighlights()
756+
}, [inputValue, spellCheckResults, updateSpellCheckHighlights])
757+
677758
const handleKeyUp = useCallback(
678759
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
679760
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
@@ -1044,6 +1125,30 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10441125
color: "transparent",
10451126
}}
10461127
/>
1128+
{isSpellCheckEnabled && (
1129+
<div
1130+
ref={spellCheckLayerRef}
1131+
className={cn(
1132+
"absolute",
1133+
"inset-0",
1134+
"pointer-events-none",
1135+
"whitespace-pre-wrap",
1136+
"break-words",
1137+
"text-transparent",
1138+
"overflow-hidden",
1139+
"font-vscode-font-family",
1140+
"text-vscode-editor-font-size",
1141+
"leading-vscode-editor-line-height",
1142+
"py-2",
1143+
"px-[9px]",
1144+
"z-[5]",
1145+
"forced-color-adjust-none",
1146+
)}
1147+
style={{
1148+
color: "transparent",
1149+
}}
1150+
/>
1151+
)}
10471152
<DynamicTextArea
10481153
ref={(el) => {
10491154
if (typeof ref === "function") {
@@ -1106,7 +1211,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
11061211
"scrollbar-none",
11071212
"scrollbar-hide",
11081213
)}
1109-
onScroll={() => updateHighlights()}
1214+
onScroll={() => {
1215+
updateHighlights()
1216+
updateSpellCheckHighlights()
1217+
}}
11101218
/>
11111219

11121220
<div className="absolute top-1 right-1 z-30">

webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,8 @@ describe("mergeExtensionState", () => {
222222
apiConfiguration: { modelMaxThinkingTokens: 456, modelTemperature: 0.3 },
223223
experiments: {
224224
powerSteering: true,
225-
marketplace: false,
226-
disableCompletionCommand: false,
227-
concurrentFileReads: true,
228225
multiFileApplyDiff: true,
226+
spellCheck: false,
229227
} as Record<ExperimentId, boolean>,
230228
}
231229

@@ -238,10 +236,8 @@ describe("mergeExtensionState", () => {
238236

239237
expect(result.experiments).toEqual({
240238
powerSteering: true,
241-
marketplace: false,
242-
disableCompletionCommand: false,
243-
concurrentFileReads: true,
244239
multiFileApplyDiff: true,
240+
spellCheck: false,
245241
})
246242
})
247243
})

webview-ui/src/i18n/locales/en/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,10 @@
648648
"MULTI_FILE_APPLY_DIFF": {
649649
"name": "Enable concurrent file edits",
650650
"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."
651+
},
652+
"SPELL_CHECK": {
653+
"name": "Enable spell check in chat",
654+
"description": "When enabled, misspelled words in the chat input will be underlined in red. This helps catch typos before sending messages to Roo."
651655
}
652656
},
653657
"promptCaching": {

webview-ui/src/index.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,14 @@ vscode-dropdown::part(listbox) {
386386
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent);
387387
}
388388

389+
/* Spell check styles */
390+
.spell-check-error {
391+
text-decoration: underline;
392+
text-decoration-color: #ff0000;
393+
text-decoration-style: wavy;
394+
text-underline-offset: 2px;
395+
}
396+
389397
/**
390398
* vscrui Overrides / Hacks
391399
*/

0 commit comments

Comments
 (0)