diff --git a/.changeset/pink-gorillas-breathe.md b/.changeset/pink-gorillas-breathe.md new file mode 100644 index 00000000000..44085877c6e --- /dev/null +++ b/.changeset/pink-gorillas-breathe.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +fix: resolve AbortSignal memory leak in CLI (MaxListenersExceededWarning) diff --git a/.changeset/pretty-memes-lose.md b/.changeset/pretty-memes-lose.md new file mode 100644 index 00000000000..79cc638e42c --- /dev/null +++ b/.changeset/pretty-memes-lose.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Split autocomplete suggestion in current line and next lines in most cases diff --git a/cli/src/state/atoms/__tests__/keyboard.test.ts b/cli/src/state/atoms/__tests__/keyboard.test.ts index c32ce7fc122..8d92aa7fee5 100644 --- a/cli/src/state/atoms/__tests__/keyboard.test.ts +++ b/cli/src/state/atoms/__tests__/keyboard.test.ts @@ -12,10 +12,13 @@ import { textBufferStringAtom, textBufferStateAtom } from "../textBuffer.js" import { keyboardHandlerAtom, submissionCallbackAtom, submitInputAtom } from "../keyboard.js" import { pendingApprovalAtom } from "../approval.js" import { historyDataAtom, historyModeAtom, historyIndexAtom as _historyIndexAtom } from "../history.js" +import { chatMessagesAtom } from "../extension.js" +import { extensionServiceAtom, isServiceReadyAtom } from "../service.js" import type { Key } from "../../../types/keyboard.js" import type { CommandSuggestion, ArgumentSuggestion, FileMentionSuggestion } from "../../../services/autocomplete.js" import type { Command } from "../../../commands/core/types.js" import type { ExtensionChatMessage } from "../../../types/messages.js" +import type { ExtensionService } from "../../../services/extension.js" describe("keypress atoms", () => { let store: ReturnType @@ -981,4 +984,108 @@ describe("keypress atoms", () => { expect(store.get(textBufferStringAtom)).toBe("") }) }) + + describe("global hotkeys", () => { + beforeEach(() => { + // Mock the extension service to prevent "ExtensionService not available" error + const mockService: Partial = { + initialize: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + sendWebviewMessage: vi.fn().mockResolvedValue(undefined), + isReady: vi.fn().mockReturnValue(true), + } + store.set(extensionServiceAtom, mockService as ExtensionService) + store.set(isServiceReadyAtom, true) + }) + + it("should cancel task when ESC is pressed while streaming", async () => { + // Set up streaming state by adding a partial message + // isStreamingAtom returns true when the last message is partial + const streamingMessage: ExtensionChatMessage = { + ts: Date.now(), + type: "say", + say: "text", + text: "Processing...", + partial: true, // This makes isStreamingAtom return true + } + store.set(chatMessagesAtom, [streamingMessage]) + + // Type some text first + const chars = ["h", "e", "l", "l", "o"] + for (const char of chars) { + const key: Key = { + name: char, + sequence: char, + ctrl: false, + meta: false, + shift: false, + paste: false, + } + store.set(keyboardHandlerAtom, key) + } + + // Verify we have text in the buffer + expect(store.get(textBufferStringAtom)).toBe("hello") + + // Press ESC while streaming + const escapeKey: Key = { + name: "escape", + sequence: "\x1b", + ctrl: false, + meta: false, + shift: false, + paste: false, + } + await store.set(keyboardHandlerAtom, escapeKey) + + // When streaming, ESC should cancel the task and NOT clear the buffer + // (because it returns early from handleGlobalHotkeys) + expect(store.get(textBufferStringAtom)).toBe("hello") + }) + + it("should clear buffer when ESC is pressed while NOT streaming", async () => { + // Set up non-streaming state by adding a complete message + const completeMessage: ExtensionChatMessage = { + ts: Date.now(), + type: "say", + say: "text", + text: "Done", + partial: false, // This makes isStreamingAtom return false + } + store.set(chatMessagesAtom, [completeMessage]) + + // Type some text + const chars = ["h", "e", "l", "l", "o"] + for (const char of chars) { + const key: Key = { + name: char, + sequence: char, + ctrl: false, + meta: false, + shift: false, + paste: false, + } + store.set(keyboardHandlerAtom, key) + } + + // Verify we have text in the buffer + expect(store.get(textBufferStringAtom)).toBe("hello") + + // Press ESC while NOT streaming + const escapeKey: Key = { + name: "escape", + sequence: "\x1b", + ctrl: false, + meta: false, + shift: false, + paste: false, + } + await store.set(keyboardHandlerAtom, escapeKey) + + // When not streaming, ESC should clear the buffer (normal behavior) + expect(store.get(textBufferStringAtom)).toBe("") + }) + }) }) diff --git a/cli/src/state/atoms/keyboard.ts b/cli/src/state/atoms/keyboard.ts index 0b3c4419c71..a5a25e55454 100644 --- a/cli/src/state/atoms/keyboard.ts +++ b/cli/src/state/atoms/keyboard.ts @@ -803,10 +803,21 @@ function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean { const isStreaming = get(isStreamingAtom) if (isStreaming) { set(cancelTaskAtom) + return true } + // If not streaming, don't consume the key + } + break + case "escape": { + // ESC cancels the task when streaming (same as Ctrl+X) + const isStreaming = get(isStreamingAtom) + if (isStreaming) { + set(cancelTaskAtom) return true } + // If not streaming, don't consume the key - let mode-specific handlers deal with it break + } case "r": if (key.ctrl) { const hasResumeTask = get(hasResumeTaskAtom) diff --git a/cli/src/state/hooks/useHotkeys.ts b/cli/src/state/hooks/useHotkeys.ts index 6d9b272ae45..863c5437327 100644 --- a/cli/src/state/hooks/useHotkeys.ts +++ b/cli/src/state/hooks/useHotkeys.ts @@ -70,7 +70,7 @@ export function useHotkeys(): UseHotkeysReturn { // Priority 3: Streaming state - show cancel if (isStreaming) { - return [{ keys: `${modifierKey}+X`, description: "to cancel" }] + return [{ keys: `Esc/${modifierKey}+X`, description: "to cancel" }] } // Priority 4: Followup suggestions visible diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 86a1724e25e..75d1a32372d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2566,26 +2566,38 @@ export class Task extends EventEmitter implements TaskLike { >() // kilocode_change end + let streamAbortSignal: AbortSignal | undefined + let streamAbortListener: (() => void) | undefined + let streamAbortPromise: Promise | undefined + try { const iterator = stream[Symbol.asyncIterator]() + const ensureAbortPromise = (): void => { + if (streamAbortPromise || !this.currentRequestAbortController) { + return + } + + streamAbortSignal = this.currentRequestAbortController.signal + streamAbortPromise = new Promise((_, reject) => { + if (streamAbortSignal!.aborted) { + reject(new Error("Request cancelled by user")) + } else { + streamAbortListener = () => reject(new Error("Request cancelled by user")) + streamAbortSignal!.addEventListener("abort", streamAbortListener) + } + }) + } + // Helper to race iterator.next() with abort signal const nextChunkWithAbort = async () => { const nextPromise = iterator.next() - // If we have an abort controller, race it with the next chunk - if (this.currentRequestAbortController) { - const abortPromise = new Promise((_, reject) => { - const signal = this.currentRequestAbortController!.signal - if (signal.aborted) { - reject(new Error("Request cancelled by user")) - } else { - signal.addEventListener("abort", () => { - reject(new Error("Request cancelled by user")) - }) - } - }) - return await Promise.race([nextPromise, abortPromise]) + // If we have an abort controller, race it with the next chunk. + // Reuse a single abort promise/listener across all chunks to avoid accumulating listeners. + ensureAbortPromise() + if (streamAbortPromise) { + return await Promise.race([nextPromise, streamAbortPromise]) } // No abort controller, just return the next chunk normally @@ -3176,6 +3188,9 @@ export class Task extends EventEmitter implements TaskLike { } } finally { this.isStreaming = false + if (streamAbortSignal && streamAbortListener) { + streamAbortSignal.removeEventListener("abort", streamAbortListener) + } // Clean up the abort controller when streaming completes this.currentRequestAbortController = undefined } @@ -4004,10 +4019,24 @@ export class Task extends EventEmitter implements TaskLike { ) const iterator = stream[Symbol.asyncIterator]() - // Set up abort handling - when the signal is aborted, clean up the controller reference - abortSignal.addEventListener("abort", () => { + // Set up abort handling - store listener reference for cleanup + // to avoid accumulating listeners on the AbortSignal + const abortCleanupListener = () => { console.log(`[Task#${this.taskId}.${this.instanceId}] AbortSignal triggered for current request`) this.currentRequestAbortController = undefined + } + abortSignal.addEventListener("abort", abortCleanupListener) + + // Create a single abort promise/listener for racing with first chunk + // to avoid accumulating listeners per attempt + let firstChunkAbortListener: (() => void) | undefined + const abortPromise = new Promise((_, reject) => { + if (abortSignal.aborted) { + reject(new Error("Request cancelled by user")) + } else { + firstChunkAbortListener = () => reject(new Error("Request cancelled by user")) + abortSignal.addEventListener("abort", firstChunkAbortListener) + } }) try { @@ -4016,15 +4045,6 @@ export class Task extends EventEmitter implements TaskLike { // Race between the first chunk and the abort signal const firstChunkPromise = iterator.next() - const abortPromise = new Promise((_, reject) => { - if (abortSignal.aborted) { - reject(new Error("Request cancelled by user")) - } else { - abortSignal.addEventListener("abort", () => { - reject(new Error("Request cancelled by user")) - }) - } - }) const firstChunk = await Promise.race([firstChunkPromise, abortPromise]) yield firstChunk.value @@ -4130,6 +4150,12 @@ export class Task extends EventEmitter implements TaskLike { Task.lastGlobalApiRequestTime = performance.now() } // kilocode_change end + + // Clean up abort listeners to prevent memory leaks + abortSignal.removeEventListener("abort", abortCleanupListener) + if (firstChunkAbortListener) { + abortSignal.removeEventListener("abort", firstChunkAbortListener) + } } // Shared exponential backoff for retries (first-chunk and mid-stream) diff --git a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts index 50a7e0c40cf..3382602959b 100644 --- a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts +++ b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts @@ -116,12 +116,101 @@ export function findMatchingSuggestion( return null } +/** + * Transforms a matching suggestion result by applying first-line-only logic if needed. + * Use this at call sites where you want to show only the first line of multi-line completions + * when the cursor is in the middle of a line. + * + * @param result - The result from findMatchingSuggestion + * @param prefix - The text before the cursor position + * @returns A new result with potentially truncated text, or null if input was null + */ +export function applyFirstLineOnly( + result: MatchingSuggestionResult | null, + prefix: string, +): MatchingSuggestionResult | null { + if (result === null || result.text === "") { + return result + } + if (shouldShowOnlyFirstLine(prefix, result.text)) { + return { text: getFirstLine(result.text), matchType: result.matchType } + } + return result +} + /** * Command ID for tracking inline completion acceptance. * This command is executed after the user accepts an inline completion. */ export const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.ghost.inline-completion.accepted" +/** + * Counts the number of lines in a text string. + * + * Notes: + * - Returns 0 for an empty string + * - A single trailing newline (or CRLF) does not count as an additional line + * + * @param text - The text to count lines in + * @returns The number of lines + */ +export function countLines(text: string): number { + if (text === "") { + return 0 + } + + // Count line breaks and add 1 for the first line. + // If the text ends with a line break, don't count the implicit trailing empty line. + const lineBreakCount = (text.match(/\r?\n/g) || []).length + const endsWithLineBreak = text.endsWith("\n") + + return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0) +} + +/** + * Determines if only the first line of a completion should be shown. + * + * The logic is: + * - If the suggestion starts with a newline → show the whole block + * - If the prefix's last line has non-whitespace text → show only the first line + * - If at start of line and suggestion is 3+ lines → show only the first line + * - Otherwise → show the whole block + * + * @param prefix - The text before the cursor position + * @param suggestion - The completion text being suggested + * @returns true if only the first line should be shown + */ +export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { + // If the suggestion starts with a newline, show the whole block + if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) { + return false + } + + // Check if the current line (before cursor) has non-whitespace text + const lastNewlineIndex = prefix.lastIndexOf("\n") + const currentLinePrefix = prefix.slice(lastNewlineIndex + 1) + + // If the current line prefix contains non-whitespace, only show the first line + if (currentLinePrefix.trim().length > 0) { + return true + } + + // At start of line (only whitespace before cursor on this line) + // Show only first line if suggestion is 3 or more lines + const lineCount = countLines(suggestion) + return lineCount >= 3 +} + +/** + * Extracts the first line from a completion text. + * + * @param text - The full completion text + * @returns The first line of the completion (without the newline) + */ +export function getFirstLine(text: string): string { + return text.split(/\r?\n/, 1)[0] +} + export function stringToInlineCompletions(text: string, position: vscode.Position): vscode.InlineCompletionItem[] { if (text === "") { return [] @@ -387,7 +476,10 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte const { prefix, suffix } = extractPrefixSuffix(document, position) // Check cache first - allow mid-word lookups from cache - const matchingResult = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory) + const matchingResult = applyFirstLineOnly( + findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), + prefix, + ) if (matchingResult !== null) { this.telemetry?.captureCacheHit(matchingResult.matchType, telemetryContext, matchingResult.text.length) @@ -407,7 +499,10 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte await this.debouncedFetchAndCacheSuggestion(prompt, promptPrefix, promptSuffix, document.languageId) - const cachedResult = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory) + const cachedResult = applyFirstLineOnly( + findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), + prefix, + ) if (cachedResult) { this.telemetry?.captureLlmSuggestionReturned(telemetryContext, cachedResult.text.length) } diff --git a/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts b/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts index d135bfc64d9..fec51207abc 100644 --- a/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts +++ b/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts @@ -2,7 +2,11 @@ import * as vscode from "vscode" import { GhostInlineCompletionProvider, findMatchingSuggestion, + applyFirstLineOnly, stringToInlineCompletions, + shouldShowOnlyFirstLine, + getFirstLine, + countLines, CostTrackingCallback, } from "../GhostInlineCompletionProvider" import { FillInAtCursorSuggestion } from "../HoleFiller" @@ -468,6 +472,81 @@ describe("findMatchingSuggestion", () => { }) }) +describe("shouldShowOnlyFirstLine", () => { + it.each([ + ["\\n", "\nconst y = 2"], + ["\\r\\n", "\r\nconst y = 2"], + ])("returns false when suggestion starts with %s", (_label, suggestion) => { + expect(shouldShowOnlyFirstLine("const x = foo", suggestion)).toBe(false) + }) + + it("returns true when cursor is mid-line (current line has non-whitespace)", () => { + expect(shouldShowOnlyFirstLine("const x = 1\nconst y = foo", "bar\nbaz")).toBe(true) + }) + + it("returns true at start of line when suggestion is 3+ lines", () => { + expect(shouldShowOnlyFirstLine("const x = 1\n ", "l1\nl2\nl3")).toBe(true) + }) + + it("returns false at start of line when suggestion is 2 lines", () => { + expect(shouldShowOnlyFirstLine("const x = 1\n", "l1\nl2")).toBe(false) + }) +}) + +describe("countLines", () => { + it("returns 0 for empty string", () => { + expect(countLines("")).toBe(0) + }) + + it("counts mixed \\n and \\r\\n correctly", () => { + expect(countLines("l1\nl2\r\nl3")).toBe(3) + }) + + it("does not count trailing newline as an additional line", () => { + expect(countLines("l1\nl2\n")).toBe(2) + }) +}) + +describe("getFirstLine", () => { + it("returns the entire text when there is no newline", () => { + expect(getFirstLine("console.log('test');")).toBe("console.log('test');") + }) + + it.each([ + ["\\n", "first line\nsecond line"], + ["\\r\\n", "first line\r\nsecond line"], + ])("returns first line for %s line endings", (_label, text) => { + expect(getFirstLine(text)).toBe("first line") + }) + + it("returns empty string when text starts with a newline", () => { + expect(getFirstLine("\nsecond line")).toBe("") + }) +}) + +describe("applyFirstLineOnly", () => { + it("returns null when input is null", () => { + expect(applyFirstLineOnly(null, "const x = foo")).toBeNull() + }) + + it("returns result unchanged when text is empty", () => { + expect(applyFirstLineOnly({ text: "", matchType: "exact" }, "const x = foo")).toEqual({ + text: "", + matchType: "exact", + }) + }) + + it("truncates to first line and preserves matchType when enabled", () => { + const result = applyFirstLineOnly({ text: "line1\nline2\nline3", matchType: "partial_typing" }, "const x = foo") + expect(result).toEqual({ text: "line1", matchType: "partial_typing" }) + }) + + it("does not truncate when suggestion starts with newline", () => { + const result = applyFirstLineOnly({ text: "\nline1\nline2", matchType: "exact" }, "const x = foo") + expect(result).toEqual({ text: "\nline1\nline2", matchType: "exact" }) + }) +}) + describe("stringToInlineCompletions", () => { it("should return empty array when text is empty string", () => { const position = new vscode.Position(0, 10) @@ -674,6 +753,24 @@ describe("GhostInlineCompletionProvider", () => { }) }) + it("should truncate cached multi-line suggestions to first line when cursor is mid-line", async () => { + provider.updateSuggestions({ + text: "line1\nline2\nline3", + prefix: "const x = 1", + suffix: "\nconst y = 2", + }) + + const result = (await provideWithDebounce( + mockDocument, + mockPosition, + mockContext, + mockToken, + )) as vscode.InlineCompletionItem[] + + expect(result).toHaveLength(1) + expect(result[0].insertText).toBe("line1") + }) + it("should return empty array when prefix does not match", async () => { const fimContent = { text: "console.log('Hello, World!');", diff --git a/src/shared/http.ts b/src/shared/http.ts index ef31685c161..9111203a2ec 100644 --- a/src/shared/http.ts +++ b/src/shared/http.ts @@ -40,19 +40,44 @@ export async function fetchWithRetries({ try { return await pRetry( async (attemptCount: number) => { - const signals: AbortSignal[] = [AbortSignal.timeout(timeout)] + const timeoutSignal = AbortSignal.timeout(timeout) + let signal: AbortSignal = timeoutSignal + let cleanup = (): void => {} + + // Avoid AbortSignal.any here because it accumulates "abort" listeners on + // long‑lived userProvidedSignal across retries/calls. if (userProvidedSignal) { - signals.push(userProvidedSignal) - } + const controller = new AbortController() + + const onUserAbort = (): void => controller.abort(userProvidedSignal.reason) + const onTimeoutAbort = (): void => controller.abort(timeoutSignal.reason) + + userProvidedSignal.addEventListener("abort", onUserAbort) + timeoutSignal.addEventListener("abort", onTimeoutAbort) - const signal = AbortSignal.any(signals) + // If the user signal was already aborted, propagate immediately. + if (userProvidedSignal.aborted) { + onUserAbort() + } + + signal = controller.signal + cleanup = (): void => { + userProvidedSignal.removeEventListener("abort", onUserAbort) + timeoutSignal.removeEventListener("abort", onTimeoutAbort) + } + } // TODO: Fix this type coercion from type 'global.Response' to type 'Response' - const res = await fetch(url, { - ...requestInit, - signal, - }) + let res: Response + try { + res = await fetch(url, { + ...requestInit, + signal, + }) + } finally { + cleanup() + } if (shouldRetry(res) && attemptCount < retries) { console.log("got bad response for", url, "status", res.status, "retrying attempt", attemptCount) @@ -65,7 +90,12 @@ export async function fetchWithRetries({ ) } catch (e) { if (e instanceof DOMException) { - throw new RequestTimedOutError(url, timeout, retries) + // Timeout errors are surfaced as DOMException("TimeoutError") + if (e.name === "TimeoutError") { + throw new RequestTimedOutError(url, timeout, retries) + } + // Propagate explicit aborts (e.g., user cancellation) + throw e } else { throw e } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 0d8062d4cce..5c051edb401 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1246,8 +1246,9 @@ export const ChatRowContent = ({ let retryInfo, code, docsURL if (message.text !== undefined) { // Try to show richer error message for that code, if available - if (parseInt(message.text.substring(0, 3)) >= 400) { - code = parseInt(message.text) + const potentialCode = parseInt(message.text.substring(0, 3)) + if (potentialCode >= 400) { + code = potentialCode const stringForError = `chat:apiRequest.errorMessage.${code}` if (i18n.exists(stringForError)) { body = t(stringForError) @@ -1267,6 +1268,8 @@ export const ChatRowContent = ({ {message.text.substring(4)}

) + } else { + body = message.text } } return (