diff --git a/.changeset/fix-chat-autocomplete-focus.md b/.changeset/fix-chat-autocomplete-focus.md new file mode 100644 index 00000000000..71da8c25179 --- /dev/null +++ b/.changeset/fix-chat-autocomplete-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix chat autocomplete to only show suggestions when textarea has focus, text hasn't changed, and clear suggestions on paste diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 83bf0b7c9f3..86f0afc3f26 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -413,12 +413,16 @@ export const ChatTextArea = forwardRef( const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(null) const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) - // const [isFocused, setIsFocused] = useState(false) // kilocode_change - not needed + const [isFocused, setIsFocused] = useState(false) // kilocode_change start: FIM autocomplete ghost text const { ghostText, handleKeyDown: handleGhostTextKeyDown, handleInputChange: handleGhostTextInputChange, + handleFocus: handleGhostTextFocus, + handleBlur: handleGhostTextBlur, + handleSelect: handleGhostTextSelect, + clearGhostText, } = useChatGhostText({ textAreaRef, enableChatAutocomplete: ghostServiceSettings?.enableChatAutocomplete ?? false, @@ -1012,9 +1016,19 @@ export const ChatTextArea = forwardRef( setShowSlashCommandsMenu(false) } // kilocode_change - // setIsFocused(false) // kilocode_change - not needed + setIsFocused(false) }, [isMouseDownOnMenu]) + // kilocode_change start: FIM autocomplete - track focus for ghost text + useEffect(() => { + if (isFocused) { + handleGhostTextFocus() + } else { + handleGhostTextBlur() + } + }, [isFocused, handleGhostTextFocus, handleGhostTextBlur]) + // kilocode_change end: FIM autocomplete + const handlePaste = useCallback( async (e: React.ClipboardEvent) => { const items = e.clipboardData.items @@ -1025,6 +1039,7 @@ export const ChatTextArea = forwardRef( const urlRegex = /^\S+:\/\/\S+$/ if (urlRegex.test(pastedText.trim())) { e.preventDefault() + clearGhostText() // kilocode_change: Clear ghost text on paste of URL as well const trimmedUrl = pastedText.trim() const newValue = inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition) @@ -1109,6 +1124,7 @@ export const ChatTextArea = forwardRef( t, selectedImages.length, showImageWarning, // kilocode_change + clearGhostText, // kilocode_change: Clear ghost text on paste ], ) @@ -1207,7 +1223,8 @@ export const ChatTextArea = forwardRef( if (textAreaRef.current) { setCursorPosition(textAreaRef.current.selectionStart) } - }, []) + handleGhostTextSelect() // kilocode_change: Clear ghost text if cursor moved away from end + }, [handleGhostTextSelect]) const handleKeyUp = useCallback( (e: React.KeyboardEvent) => { @@ -1548,7 +1565,7 @@ export const ChatTextArea = forwardRef( updateHighlights() } }} - // onFocus={() => setIsFocused(true)} // kilocode_change - not needed + onFocus={() => setIsFocused(true)} onKeyDown={(e) => { // Handle ESC to cancel in edit mode if (isEditMode && e.key === "Escape" && !e.nativeEvent?.isComposing) { diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx index 81298e76e18..becd904539c 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx @@ -1,6 +1,7 @@ -import { renderHook, act, waitFor } from "@testing-library/react" +import { renderHook, act } from "@testing-library/react" import { vi } from "vitest" import { useChatGhostText } from "../useChatGhostText" +import { vscode } from "@/utils/vscode" // Mock vscode vi.mock("@/utils/vscode", () => ({ @@ -9,13 +10,64 @@ vi.mock("@/utils/vscode", () => ({ }, })) +// Mock generateRequestId to return a predictable value +const MOCK_REQUEST_ID = "test-request-id" +vi.mock("@roo/id", () => ({ + generateRequestId: () => MOCK_REQUEST_ID, +})) + +// Helper to simulate the full flow: focus -> input change -> completion result +function simulateCompletionFlow( + result: ReturnType, + mockTextArea: HTMLTextAreaElement, + text: string, + ghostText: string, +) { + // First, focus the textarea + act(() => { + result.handleFocus() + }) + + // Then simulate input change (this sets the prefix and triggers completion request) + act(() => { + mockTextArea.value = text + mockTextArea.selectionStart = text.length + mockTextArea.selectionEnd = text.length + const changeEvent = { + target: mockTextArea, + } as React.ChangeEvent + result.handleInputChange(changeEvent) + }) + + // Advance timers to trigger the debounced completion request + act(() => { + vi.advanceTimersByTime(300) + }) + + // Finally, simulate receiving the completion result + // The requestId should match what was set during handleInputChange + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: ghostText, + requestId: MOCK_REQUEST_ID, + }, + }) + window.dispatchEvent(messageEvent) + }) +} + describe("useChatGhostText", () => { let mockTextArea: HTMLTextAreaElement let textAreaRef: React.RefObject beforeEach(() => { + vi.useFakeTimers() mockTextArea = document.createElement("textarea") mockTextArea.value = "Hello world" + mockTextArea.selectionStart = 11 + mockTextArea.selectionEnd = 11 document.body.appendChild(mockTextArea) textAreaRef = { current: mockTextArea } document.execCommand = vi.fn(() => true) @@ -24,6 +76,7 @@ describe("useChatGhostText", () => { afterEach(() => { document.body.removeChild(mockTextArea) vi.clearAllMocks() + vi.useRealTimers() }) describe("Tab key acceptance", () => { @@ -35,22 +88,11 @@ describe("useChatGhostText", () => { }), ) - // Simulate receiving ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " completion text", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow: focus -> input -> completion result + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion text") - // Wait for ghost text to be set - waitFor(() => { - expect(result.current.ghostText).toBe(" completion text") - }) + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion text") // Simulate Tab key press const tabEvent = { @@ -71,10 +113,6 @@ describe("useChatGhostText", () => { describe("Right Arrow key - word-by-word acceptance", () => { it("should accept next word when cursor is at end", () => { - mockTextArea.value = "Hello world" - mockTextArea.selectionStart = 11 // At end - mockTextArea.selectionEnd = 11 - const { result } = renderHook(() => useChatGhostText({ textAreaRef, @@ -82,17 +120,11 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text manually for test - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " this is more text", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " this is more text") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" this is more text") // Simulate Right Arrow key press const arrowEvent = { @@ -124,17 +156,11 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " word1 word2 word3", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Start", " word1 word2 word3") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" word1 word2 word3") const arrowEvent = { key: "ArrowRight", @@ -164,10 +190,6 @@ describe("useChatGhostText", () => { }) it("should NOT accept word when cursor is not at end", () => { - mockTextArea.value = "Hello world" - mockTextArea.selectionStart = 5 // In middle - mockTextArea.selectionEnd = 5 - const { result } = renderHook(() => useChatGhostText({ textAreaRef, @@ -175,17 +197,15 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " more text", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " more text") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" more text") + + // Move cursor to middle + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 const arrowEvent = { key: "ArrowRight", @@ -203,9 +223,9 @@ describe("useChatGhostText", () => { }) it("should NOT accept word with Shift modifier", () => { - mockTextArea.value = "Test" - mockTextArea.selectionStart = 4 - mockTextArea.selectionEnd = 4 + mockTextArea.value = "Test1" + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 const { result } = renderHook(() => useChatGhostText({ @@ -214,17 +234,11 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " text", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Test1", " text") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" text") const arrowEvent = { key: "ArrowRight", @@ -241,9 +255,9 @@ describe("useChatGhostText", () => { }) it("should NOT accept word with Ctrl modifier", () => { - mockTextArea.value = "Test" - mockTextArea.selectionStart = 4 - mockTextArea.selectionEnd = 4 + mockTextArea.value = "Test2" + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 const { result } = renderHook(() => useChatGhostText({ @@ -252,17 +266,11 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " text", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Test2", " text") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" text") const arrowEvent = { key: "ArrowRight", @@ -288,18 +296,10 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " world", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " world") + // Verify ghost text is set expect(result.current.ghostText).toBe(" world") // Simulate Escape key @@ -317,9 +317,9 @@ describe("useChatGhostText", () => { describe("Edge cases", () => { it("should handle ghost text with only whitespace", () => { - mockTextArea.value = "Test" - mockTextArea.selectionStart = 4 - mockTextArea.selectionEnd = 4 + mockTextArea.value = "Test3" + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 const { result } = renderHook(() => useChatGhostText({ @@ -328,17 +328,11 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text with only whitespace - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: " ", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow with whitespace-only ghost text + simulateCompletionFlow(result.current, mockTextArea, "Test3", " ") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" ") const arrowEvent = { key: "ArrowRight", @@ -357,9 +351,9 @@ describe("useChatGhostText", () => { }) it("should handle single word ghost text", () => { - mockTextArea.value = "Test" - mockTextArea.selectionStart = 4 - mockTextArea.selectionEnd = 4 + mockTextArea.value = "Test4" + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 const { result } = renderHook(() => useChatGhostText({ @@ -368,17 +362,11 @@ describe("useChatGhostText", () => { }), ) - // Set single word ghost text - act(() => { - const messageEvent = new MessageEvent("message", { - data: { - type: "chatCompletionResult", - text: "word", - requestId: "", - }, - }) - window.dispatchEvent(messageEvent) - }) + // Simulate the full flow with single word ghost text + simulateCompletionFlow(result.current, mockTextArea, "Test4", "word") + + // Verify ghost text is set + expect(result.current.ghostText).toBe("word") const arrowEvent = { key: "ArrowRight", @@ -397,9 +385,9 @@ describe("useChatGhostText", () => { }) it("should handle empty ghost text gracefully", () => { - mockTextArea.value = "Test" - mockTextArea.selectionStart = 4 - mockTextArea.selectionEnd = 4 + mockTextArea.value = "Test5" + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 const { result } = renderHook(() => useChatGhostText({ @@ -432,25 +420,493 @@ describe("useChatGhostText", () => { }), ) - // Set ghost text + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + act(() => { + result.current.clearGhostText() + }) + + expect(result.current.ghostText).toBe("") + }) + }) + + describe("Focus and blur behavior", () => { + it("should clear ghost text on blur and restore on focus", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Blur the textarea + act(() => { + result.current.handleBlur() + }) + + expect(result.current.ghostText).toBe("") + + // Focus the textarea again - ghost text should be restored + act(() => { + result.current.handleFocus() + }) + + expect(result.current.ghostText).toBe(" completion") + }) + + it("should not restore ghost text if text changed while unfocused", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Blur the textarea + act(() => { + result.current.handleBlur() + }) + + expect(result.current.ghostText).toBe("") + + // Change the text while unfocused (simulating external change) + mockTextArea.value = "Different text" + mockTextArea.selectionStart = 14 + mockTextArea.selectionEnd = 14 + + // Focus the textarea again - ghost text should NOT be restored + act(() => { + result.current.handleFocus() + }) + + expect(result.current.ghostText).toBe("") + }) + + it("should not request completion when not focused", () => { + // Clear any previous calls from beforeEach + vi.mocked(vscode.postMessage).mockClear() + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Don't call handleFocus - simulate typing without focus + act(() => { + mockTextArea.value = "Hello world" + const changeEvent = { + target: mockTextArea, + } as React.ChangeEvent + result.current.handleInputChange(changeEvent) + }) + + // Advance timers + act(() => { + vi.advanceTimersByTime(300) + }) + + // Verify that no completion request was made because we weren't focused + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "requestChatCompletion" }), + ) + + // Ghost text should remain empty + expect(result.current.ghostText).toBe("") + }) + + it("should discard completion result if text changed", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Focus and type + act(() => { + result.current.handleFocus() + }) + + act(() => { + mockTextArea.value = "Hello world" + mockTextArea.selectionStart = 11 + mockTextArea.selectionEnd = 11 + const changeEvent = { + target: mockTextArea, + } as React.ChangeEvent + result.current.handleInputChange(changeEvent) + }) + + // Advance timers to trigger completion request + act(() => { + vi.advanceTimersByTime(300) + }) + + // Change the text before completion arrives (simulating paste or external change) + mockTextArea.value = "Different text" + + // Simulate receiving completion result for the old text + // Use the correct requestId to ensure we're testing the prefix mismatch, not requestId mismatch + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " completion", + requestId: MOCK_REQUEST_ID, + }, + }) + window.dispatchEvent(messageEvent) + }) + + // Ghost text should not be set because the text changed (prefix mismatch) + expect(result.current.ghostText).toBe("") + }) + + it("should discard completion result if cursor is not at end", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Focus and type + act(() => { + result.current.handleFocus() + }) + + act(() => { + mockTextArea.value = "Hello world" + mockTextArea.selectionStart = 11 + mockTextArea.selectionEnd = 11 + const changeEvent = { + target: mockTextArea, + } as React.ChangeEvent + result.current.handleInputChange(changeEvent) + }) + + // Advance timers to trigger completion request + act(() => { + vi.advanceTimersByTime(300) + }) + + // Move cursor to middle before completion arrives + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 + + // Simulate receiving completion result act(() => { const messageEvent = new MessageEvent("message", { data: { type: "chatCompletionResult", text: " completion", - requestId: "", + requestId: MOCK_REQUEST_ID, }, }) window.dispatchEvent(messageEvent) }) + // Ghost text should not be set because cursor is not at end + expect(result.current.ghostText).toBe("") + }) + + it("should clear ghost text when cursor moves away from end via handleSelect", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set expect(result.current.ghostText).toBe(" completion") + // Move cursor to middle (simulating user clicking or using arrow keys) + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 + + // Call handleSelect (this is called on selection change events) act(() => { - result.current.clearGhostText() + result.current.handleSelect() + }) + + // Ghost text should be cleared because cursor is no longer at end + expect(result.current.ghostText).toBe("") + }) + + it("should not clear ghost text when cursor is still at end via handleSelect", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Cursor is still at end + mockTextArea.selectionStart = 11 + mockTextArea.selectionEnd = 11 + + // Call handleSelect + act(() => { + result.current.handleSelect() + }) + + // Ghost text should still be there + expect(result.current.ghostText).toBe(" completion") + }) + + it("should clear ghost text when there is a selection via handleSelect", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Create a selection (not just cursor position) + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 11 + + // Call handleSelect + act(() => { + result.current.handleSelect() + }) + + // Ghost text should be cleared because there's a selection + expect(result.current.ghostText).toBe("") + }) + + it("should restore ghost text when cursor returns to end via handleSelect", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Move cursor to middle + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 + + // Call handleSelect - ghost text should be cleared + act(() => { + result.current.handleSelect() + }) + expect(result.current.ghostText).toBe("") + + // Move cursor back to end + mockTextArea.selectionStart = 11 + mockTextArea.selectionEnd = 11 + + // Call handleSelect again - ghost text should be restored + act(() => { + result.current.handleSelect() + }) + expect(result.current.ghostText).toBe(" completion") + }) + + it("should not restore ghost text when cursor returns to end if text changed", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Move cursor to middle + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 + + // Call handleSelect - ghost text should be cleared + act(() => { + result.current.handleSelect() + }) + expect(result.current.ghostText).toBe("") + + // Change the text + mockTextArea.value = "Different text" + mockTextArea.selectionStart = 14 + mockTextArea.selectionEnd = 14 + + // Call handleSelect again - ghost text should NOT be restored because text changed + act(() => { + result.current.handleSelect() + }) + expect(result.current.ghostText).toBe("") + }) + + it("should cancel pending completion requests on blur", () => { + // Clear any previous calls + vi.mocked(vscode.postMessage).mockClear() + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Focus and type + act(() => { + result.current.handleFocus() + }) + + act(() => { + mockTextArea.value = "Hello world" + mockTextArea.selectionStart = 11 + mockTextArea.selectionEnd = 11 + const changeEvent = { + target: mockTextArea, + } as React.ChangeEvent + result.current.handleInputChange(changeEvent) + }) + + // Blur before the debounce timer fires + act(() => { + result.current.handleBlur() + }) + + // Advance timers past the debounce period + act(() => { + vi.advanceTimersByTime(300) + }) + + // Verify that no completion request was made because blur cancelled it + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "requestChatCompletion" }), + ) + }) + + it("should not restore ghost text on focus if cursor is not at end", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Blur the textarea + act(() => { + result.current.handleBlur() + }) + + expect(result.current.ghostText).toBe("") + + // Move cursor to middle while unfocused + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 + + // Focus the textarea again - ghost text should NOT be restored because cursor is not at end + act(() => { + result.current.handleFocus() + }) + + expect(result.current.ghostText).toBe("") + }) + + it("should handle null textAreaRef gracefully in handleSelect", () => { + const nullRef = { current: null } as React.RefObject + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef: nullRef, + enableChatAutocomplete: true, + }), + ) + + // This should not throw + act(() => { + result.current.handleSelect() + }) + + expect(result.current.ghostText).toBe("") + }) + + it("should handle rapid focus/blur/focus cycles correctly", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate the full flow + simulateCompletionFlow(result.current, mockTextArea, "Hello world", " completion") + + // Verify ghost text is set + expect(result.current.ghostText).toBe(" completion") + + // Rapid blur/focus/blur/focus + act(() => { + result.current.handleBlur() }) + expect(result.current.ghostText).toBe("") + + act(() => { + result.current.handleFocus() + }) + expect(result.current.ghostText).toBe(" completion") + act(() => { + result.current.handleBlur() + }) expect(result.current.ghostText).toBe("") + + act(() => { + result.current.handleFocus() + }) + expect(result.current.ghostText).toBe(" completion") }) }) }) diff --git a/webview-ui/src/components/chat/hooks/useChatGhostText.ts b/webview-ui/src/components/chat/hooks/useChatGhostText.ts index a31824c53ba..59960e20c1d 100644 --- a/webview-ui/src/components/chat/hooks/useChatGhostText.ts +++ b/webview-ui/src/components/chat/hooks/useChatGhostText.ts @@ -13,6 +13,9 @@ interface UseChatGhostTextReturn { ghostText: string handleKeyDown: (event: React.KeyboardEvent) => boolean // Returns true if event was handled handleInputChange: (e: React.ChangeEvent) => void + handleFocus: () => void + handleBlur: () => void + handleSelect: () => void clearGhostText: () => void } @@ -25,9 +28,39 @@ export function useChatGhostText({ enableChatAutocomplete = true, }: UseChatGhostTextOptions): UseChatGhostTextReturn { const [ghostText, setGhostText] = useState("") + const isFocusedRef = useRef(false) const completionDebounceRef = useRef(null) const completionRequestIdRef = useRef("") + const completionPrefixRef = useRef("") // Track the prefix used for the current request const skipNextCompletionRef = useRef(false) // Skip completion after accepting suggestion + const savedGhostTextRef = useRef("") // Store ghost text when blurring to restore on focus + const savedPrefixRef = useRef("") // Store the prefix associated with saved ghost text + + /** + * Idempotent function to synchronize ghost text visibility based on current state. + * This is the single source of truth for whether ghost text should be shown. + */ + const syncGhostTextVisibility = useCallback(() => { + const textArea = textAreaRef.current + if (!textArea) return + + const currentText = textArea.value + const isCursorAtEnd = + textArea.selectionStart === currentText.length && textArea.selectionEnd === currentText.length + + // Ghost text should only be visible when: + // 1. The textarea is focused + // 2. The cursor is at the end of the text + // 3. We have saved ghost text that matches the current prefix + const shouldShowGhostText = + isFocusedRef.current && isCursorAtEnd && savedGhostTextRef.current && currentText === savedPrefixRef.current + + if (shouldShowGhostText) { + setGhostText(savedGhostTextRef.current) + } else { + setGhostText("") + } + }, [textAreaRef]) // Handle chat completion result messages useEffect(() => { @@ -35,17 +68,36 @@ export function useChatGhostText({ const message = event.data if (message.type === "chatCompletionResult") { // Only update if this is the response to our latest request - if (message.requestId === completionRequestIdRef.current) { - setGhostText(message.text || "") + // and the textarea is still focused + if (message.requestId === completionRequestIdRef.current && isFocusedRef.current) { + const textArea = textAreaRef.current + if (!textArea) return + + // Verify the current text still matches the prefix used for this request + const currentText = textArea.value + const expectedPrefix = completionPrefixRef.current + + // Also verify cursor is at the end (since we only show suggestions at the end) + const isCursorAtEnd = textArea.selectionStart === currentText.length + + if (currentText === expectedPrefix && isCursorAtEnd) { + // Store the new ghost text and sync visibility + savedGhostTextRef.current = message.text || "" + savedPrefixRef.current = currentText + syncGhostTextVisibility() + } + // If prefix doesn't match or cursor not at end, discard the suggestion silently } } } window.addEventListener("message", messageHandler) return () => window.removeEventListener("message", messageHandler) - }, []) + }, [textAreaRef, syncGhostTextVisibility]) const clearGhostText = useCallback(() => { + savedGhostTextRef.current = "" + savedPrefixRef.current = "" setGhostText("") }, []) @@ -70,7 +122,7 @@ export function useChatGhostText({ type: "chatCompletionAccepted", suggestionLength: ghostText.length, }) - setGhostText("") + clearGhostText() return true } @@ -91,25 +143,28 @@ export function useChatGhostText({ type: "chatCompletionAccepted", suggestionLength: word.length, }) - setGhostText(remainder) + // Update saved ghost text with remainder and sync + savedGhostTextRef.current = remainder + savedPrefixRef.current = textArea.value + syncGhostTextVisibility() return true } // Escape: Clear ghost text if (event.key === "Escape" && ghostText) { - setGhostText("") + clearGhostText() } return false }, - [ghostText, textAreaRef], + [ghostText, textAreaRef, clearGhostText, syncGhostTextVisibility], ) const handleInputChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value - // Clear any existing ghost text when typing - setGhostText("") + // Clear saved ghost text since the text has changed + clearGhostText() // Clear any pending completion request if (completionDebounceRef.current) { @@ -122,13 +177,15 @@ export function useChatGhostText({ // Don't request a new completion - wait for user to type more } else if ( enableChatAutocomplete && + isFocusedRef.current && newValue.length >= 5 && !newValue.startsWith("/") && !newValue.includes("@") ) { - // Request new completion after debounce (only if feature is enabled) + // Request new completion after debounce (only if feature is enabled and textarea is focused) const requestId = generateRequestId() completionRequestIdRef.current = requestId + completionPrefixRef.current = newValue // Store the prefix used for this request completionDebounceRef.current = setTimeout(() => { vscode.postMessage({ type: "requestChatCompletion", @@ -138,9 +195,29 @@ export function useChatGhostText({ }, 300) // 300ms debounce } }, - [enableChatAutocomplete], + [enableChatAutocomplete, clearGhostText], ) + const handleFocus = useCallback(() => { + isFocusedRef.current = true + syncGhostTextVisibility() + }, [syncGhostTextVisibility]) + + const handleBlur = useCallback(() => { + isFocusedRef.current = false + syncGhostTextVisibility() + + // Cancel any pending completion requests + if (completionDebounceRef.current) { + clearTimeout(completionDebounceRef.current) + completionDebounceRef.current = null + } + }, [syncGhostTextVisibility]) + + const handleSelect = useCallback(() => { + syncGhostTextVisibility() + }, [syncGhostTextVisibility]) + useEffect(() => { return () => { if (completionDebounceRef.current) { @@ -153,6 +230,9 @@ export function useChatGhostText({ ghostText, handleKeyDown, handleInputChange, + handleFocus, + handleBlur, + handleSelect, clearGhostText, } }