From eb0bc32ac6d85c2d60e38f81a0887d530747bfef Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:16:40 +0000 Subject: [PATCH 1/5] feat: add .dist.* files to ManagedIndexing ignore list --- src/services/code-index/managed/ignore-list.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/code-index/managed/ignore-list.ts b/src/services/code-index/managed/ignore-list.ts index 0158ab476c..6eac68497b 100644 --- a/src/services/code-index/managed/ignore-list.ts +++ b/src/services/code-index/managed/ignore-list.ts @@ -41,7 +41,8 @@ export function shouldIgnoreFile(relativeFilePath: string): boolean { ext === ".min.js" || ext === ".min.css" || ext === ".bundle.js" || - ext === ".map" + ext === ".map" || + fileName.match(/\.dist\..+$/) ) { return true } From 3de254757049d08d3c0c100768acc564d6de4888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 19 Dec 2025 13:23:19 -0300 Subject: [PATCH 2/5] feat: autocomplete telemetry --- .changeset/stale-towns-bathe.md | 5 ++ jetbrains/plugin/build.gradle.kts | 45 ++++++----- .../actions/GitCommitMessageAction.kt | 17 ++-- .../actors/MainThreadLanguageFeaturesShape.kt | 10 +-- .../jetbrains/commands/SetContextCommands.kt | 26 +++--- .../kilocode/jetbrains/core/ContextManager.kt | 40 ++++----- .../jetbrains/git/CommitMessageConstants.kt | 2 +- .../jetbrains/git/CommitMessageHandler.kt | 22 ++--- .../jetbrains/git/FileDiscoveryService.kt | 32 ++++---- .../inline/InlineCompletionConstants.kt | 4 +- .../inline/InlineCompletionManager.kt | 81 +++++++++---------- .../inline/InlineCompletionService.kt | 39 ++++----- .../KiloCodeInlineCompletionInsertHandler.kt | 77 ++++++++++++++++++ .../KiloCodeInlineCompletionProvider.kt | 44 +++++----- pnpm-lock.yaml | 3 +- src/services/ghost/GhostServiceManager.ts | 3 +- 16 files changed, 267 insertions(+), 183 deletions(-) create mode 100644 .changeset/stale-towns-bathe.md create mode 100644 jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt diff --git a/.changeset/stale-towns-bathe.md b/.changeset/stale-towns-bathe.md new file mode 100644 index 0000000000..ac7605fa33 --- /dev/null +++ b/.changeset/stale-towns-bathe.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Jetbrains - Autocomplete Telemetry diff --git a/jetbrains/plugin/build.gradle.kts b/jetbrains/plugin/build.gradle.kts index a87d76d448..d0de54f408 100644 --- a/jetbrains/plugin/build.gradle.kts +++ b/jetbrains/plugin/build.gradle.kts @@ -61,7 +61,6 @@ project.afterEvaluate { tasks.findByName(":prepareSandbox")?.inputs?.properties?.put("build_mode", ext.get("debugMode")) } - group = properties("pluginGroup").get() version = properties("pluginVersion").get() @@ -152,60 +151,62 @@ tasks { println("Configuration file generated: ${configFile.absolutePath}") } } - buildPlugin { dependsOn(prepareSandbox) - + // Include the jetbrains directory contents from sandbox in the distribution root doLast { if (ext.get("debugMode") != "idea" && ext.get("debugMode") != "none") { val distributionFile = archiveFile.get().asFile val sandboxPluginsDir = layout.buildDirectory.get().asFile.resolve("idea-sandbox/IC-2024.3/plugins") val jetbrainsDir = sandboxPluginsDir.resolve("jetbrains") - + if (jetbrainsDir.exists() && distributionFile.exists()) { logger.lifecycle("Adding sandbox resources to distribution ZIP...") logger.lifecycle("Sandbox jetbrains dir: ${jetbrainsDir.absolutePath}") logger.lifecycle("Distribution file: ${distributionFile.absolutePath}") - + // Extract the existing ZIP val tempDir = layout.buildDirectory.get().asFile.resolve("temp-dist") tempDir.deleteRecursively() tempDir.mkdirs() - + copy { from(zipTree(distributionFile)) into(tempDir) } - + // Copy jetbrains directory CONTENTS directly to plugin root (not the jetbrains folder itself) val pluginDir = tempDir.resolve(rootProject.name) copy { from(jetbrainsDir) // Copy contents of jetbrains dir - into(pluginDir) // Directly into plugin root + into(pluginDir) // Directly into plugin root } - + // Re-create the ZIP with resources included distributionFile.delete() - ant.invokeMethod("zip", mapOf( - "destfile" to distributionFile.absolutePath, - "basedir" to tempDir.absolutePath - )) - + ant.invokeMethod( + "zip", + mapOf( + "destfile" to distributionFile.absolutePath, + "basedir" to tempDir.absolutePath, + ), + ) + // Clean up temp directory tempDir.deleteRecursively() - + logger.lifecycle("Distribution ZIP updated with sandbox resources at root level") } } } } - + prepareSandbox { dependsOn("generateConfigProperties") duplicatesStrategy = DuplicatesStrategy.INCLUDE - + if (ext.get("debugMode") == "idea") { from("${project.projectDir.absolutePath}/src/main/resources/themes/") { into("${ext.get("debugResource")}/${ext.get("vscodePlugin")}/integrations/theme/default-themes/") @@ -226,14 +227,14 @@ tasks { if (!depfile.exists()) { throw IllegalStateException("missing prodDep.txt") } - + // Handle platform.zip for release mode if (ext.get("debugMode") == "release") { val platformZip = File("platform.zip") if (!platformZip.exists() || platformZip.length() < 1024 * 1024) { throw IllegalStateException("platform.zip file does not exist or is smaller than 1MB. This file is supported through git lfs and needs to be obtained through git lfs") } - + // Extract platform.zip to the platform subdirectory under the project build directory val platformDir = File("${layout.buildDirectory.get().asFile}/platform") platformDir.mkdirs() @@ -243,11 +244,11 @@ tasks { } } } - + val vscodePluginDir = File("./plugins/${ext.get("vscodePlugin")}") val depfile = File("prodDep.txt") val list = mutableListOf() - + // Read dependencies during execution doFirst { depfile.readLines().forEach { line -> @@ -273,7 +274,7 @@ tasks { // Copy VSCode plugin extension from("${vscodePluginDir.path}/extension") { into("$pluginName/${ext.get("vscodePlugin")}") } - + // Copy themes from("src/main/resources/themes/") { into("$pluginName/${ext.get("vscodePlugin")}/integrations/theme/default-themes/") } diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt index c3332e6a41..86a6327be3 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt @@ -6,8 +6,8 @@ package ai.kilocode.jetbrains.actions import ai.kilocode.jetbrains.git.CommitMessageService -import ai.kilocode.jetbrains.git.WorkspaceResolver import ai.kilocode.jetbrains.git.FileDiscoveryService +import ai.kilocode.jetbrains.git.WorkspaceResolver import ai.kilocode.jetbrains.i18n.I18n import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction @@ -85,7 +85,7 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera project: Project, commitControl: CommitMessageUi, workspacePath: String, - dataContext: DataContext + dataContext: DataContext, ) { ProgressManager.getInstance().run(object : Task.Backgroundable( project, @@ -128,8 +128,10 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera ApplicationManager.getApplication().invokeLater { Messages.showErrorDialog( project, - I18n.t("kilocode:commitMessage.errors.processingError", - mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown")))), + I18n.t( + "kilocode:commitMessage.errors.processingError", + mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown"))), + ), I18n.t("kilocode:commitMessage.dialogs.error"), ) } @@ -180,8 +182,10 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera ApplicationManager.getApplication().invokeLater { Messages.showErrorDialog( project, - I18n.t("kilocode:commitMessage.errors.processingError", - mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown")))), + I18n.t( + "kilocode:commitMessage.errors.processingError", + mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown"))), + ), I18n.t("kilocode:commitMessage.dialogs.error"), ) } @@ -210,7 +214,6 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera } } - companion object { val PENDING_COMMIT_MESSAGE_KEY = com.intellij.openapi.util.Key.create("KILOCODE_PENDING_COMMIT_MESSAGE") } diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt index f4a8ebba31..f37bf35425 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt @@ -448,7 +448,7 @@ interface MainThreadLanguageFeaturesShape : Disposable { */ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLanguageFeaturesShape { private val logger = Logger.getInstance(MainThreadLanguageFeatures::class.java) - + /** * Manager for inline completion providers. * Handles registration, unregistration, and lifecycle management. @@ -459,7 +459,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu override fun unregister(handle: Int) { logger.info("Unregistering service: handle=$handle") - + // Try to unregister from inline completion manager try { inlineCompletionManager.unregisterProvider(handle) @@ -628,7 +628,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu debounceDelayMs: Int?, ) { logger.info("Registering inline completions support: handle=$handle, extensionId=$extensionId, displayName=$displayName") - + try { inlineCompletionManager.registerProvider( handle = handle, @@ -637,7 +637,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu extensionId = extensionId, yieldsToExtensionIds = yieldsToExtensionIds, displayName = displayName, - debounceDelayMs = debounceDelayMs + debounceDelayMs = debounceDelayMs, ) logger.info("Successfully registered inline completion provider: handle=$handle") } catch (e: Exception) { @@ -737,7 +737,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu override fun dispose() { logger.info("Disposing MainThreadLanguageFeatures resources") - + // Dispose inline completion manager try { inlineCompletionManager.dispose() diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt index d137179787..c6bfdfa9b2 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt @@ -6,11 +6,11 @@ import com.intellij.openapi.project.Project /** * Registers the setContext command for managing VSCode-style context keys. - * + * * The setContext command allows the extension to set context values that can be used * to control UI state and feature availability. This is commonly used by features like * the GhostProvider (autocomplete) to enable/disable keybindings dynamically. - * + * * @param project The current IntelliJ project * @param registry The command registry to register commands with */ @@ -21,32 +21,32 @@ fun registerSetContextCommands(project: Project, registry: CommandRegistry) { override fun getId(): String { return "setContext" } - + override fun getMethod(): String { return "setContext" } - + override fun handler(): Any { return SetContextCommands(project) } - + override fun returns(): String? { return "void" } }, ) - + // Register alias with underscore prefix for compatibility with VSCode registry.registerCommandAlias("setContext", "_setContext") } /** * Handles setContext command operations for managing context keys. - * + * * This class provides the implementation for the setContext command, which allows * setting context key-value pairs that can be used throughout the plugin to control * feature availability and UI state. - * + * * Example context keys used by GhostProvider: * - kilocode.ghost.enableQuickInlineTaskKeybinding * - kilocode.ghost.enableSmartInlineTaskKeybinding @@ -54,13 +54,13 @@ fun registerSetContextCommands(project: Project, registry: CommandRegistry) { class SetContextCommands(val project: Project) { private val logger = Logger.getInstance(SetContextCommands::class.java) private val contextManager = ContextManager.getInstance(project) - + /** * Sets a context value for the given key. - * + * * This method is called when the setContext command is executed from the extension. * It stores the key-value pair in the ContextManager for later retrieval. - * + * * @param key The context key to set (e.g., "kilocode.ghost.enableQuickInlineTaskKeybinding") * @param value The value to set (typically Boolean, but can be String, Number, etc.) * @return null (void return type) @@ -74,7 +74,7 @@ class SetContextCommands(val project: Project) { logger.error("Failed to set context: $key = $value", e) throw e } - + return null } -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt index d6a3d94a10..1b2e38ff49 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt @@ -7,13 +7,13 @@ import java.util.concurrent.ConcurrentHashMap /** * Context Manager Service - * + * * Manages VSCode-style context keys for the JetBrains plugin. * Provides thread-safe storage for context key-value pairs that can be used * to control UI state and feature availability. - * + * * This service is project-scoped, meaning each project has its own context storage. - * + * * Example usage: * ```kotlin * val contextManager = project.getService(ContextManager::class.java) @@ -24,16 +24,16 @@ import java.util.concurrent.ConcurrentHashMap @Service(Service.Level.PROJECT) class ContextManager { private val logger = Logger.getInstance(ContextManager::class.java) - + /** * Thread-safe storage for context key-value pairs */ private val contexts = ConcurrentHashMap() - + /** * Sets a context value for the given key. * If the value is null, the context key will be removed. - * + * * @param key The context key (e.g., "kilocode.ghost.enableQuickInlineTaskKeybinding") * @param value The value to set (can be Boolean, String, Number, or any serializable type) */ @@ -42,9 +42,9 @@ class ContextManager { removeContext(key) return } - + val previousValue = contexts.put(key, value) - + if (logger.isDebugEnabled) { if (previousValue != null) { logger.debug("Context updated: $key = $value (previous: $previousValue)") @@ -53,30 +53,30 @@ class ContextManager { } } } - + /** * Gets the context value for the given key. - * + * * @param key The context key to retrieve * @return The context value, or null if the key doesn't exist */ fun getContext(key: String): Any? { return contexts[key] } - + /** * Checks if a context key exists. - * + * * @param key The context key to check * @return true if the key exists, false otherwise */ fun hasContext(key: String): Boolean { return contexts.containsKey(key) } - + /** * Removes a context key and its value. - * + * * @param key The context key to remove */ fun removeContext(key: String) { @@ -85,17 +85,17 @@ class ContextManager { logger.debug("Context removed: $key (previous value: $previousValue)") } } - + /** * Gets all context keys and their values. * Returns a copy of the context map to prevent external modification. - * + * * @return A map of all context keys and values */ fun getAllContexts(): Map { return contexts.toMap() } - + /** * Clears all context keys. * This is typically used during cleanup or reset operations. @@ -107,11 +107,11 @@ class ContextManager { logger.debug("Cleared all contexts ($count keys)") } } - + companion object { /** * Gets the ContextManager instance for the given project. - * + * * @param project The project to get the ContextManager for * @return The ContextManager instance */ @@ -119,4 +119,4 @@ class ContextManager { return project.getService(ContextManager::class.java) } } -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt index a2cd2f4095..f15a832121 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt @@ -12,7 +12,7 @@ object CommitMessageConstants { * VSCode extension command ID for external commit message generation. */ const val EXTERNAL_COMMAND_ID = "kilo-code.jetbrains.generateCommitMessage" - + /** * Default timeout in milliseconds for commit message generation requests. */ diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt index ded4bc8ec7..fffbb3d6ad 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt @@ -6,9 +6,9 @@ package ai.kilocode.jetbrains.git import ai.kilocode.jetbrains.actions.GitCommitMessageAction import ai.kilocode.jetbrains.i18n.I18n +import com.intellij.openapi.actionSystem.impl.SimpleDataContext import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressIndicator @@ -16,20 +16,15 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages -import com.intellij.openapi.actionSystem.DataContext -import com.intellij.openapi.actionSystem.impl.SimpleDataContext import com.intellij.openapi.vcs.CheckinProjectPanel import com.intellij.openapi.vcs.changes.CommitContext import com.intellij.openapi.vcs.checkin.CheckinHandler import com.intellij.openapi.vcs.checkin.CheckinHandler.ReturnResult -import com.intellij.openapi.vcs.ui.RefreshableOnComponent import com.intellij.openapi.vcs.ui.Refreshable +import com.intellij.openapi.vcs.ui.RefreshableOnComponent import com.intellij.util.ui.FormBuilder import kotlinx.coroutines.* import java.awt.BorderLayout -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException import javax.swing.JButton import javax.swing.JPanel @@ -178,7 +173,7 @@ class CommitMessageHandler( ) { try { indicator.text = I18n.t("kilocode:commitMessage.progress.connecting") - + val files = try { runBlocking { withTimeout(5000) { @@ -192,9 +187,9 @@ class CommitMessageHandler( logger.warn("Error getting selected files: ${e.message}") emptyList() } - + indicator.text = I18n.t("kilocode:commitMessage.progress.generating") - + // Generate message on background thread val result = try { runBlocking { @@ -204,12 +199,12 @@ class CommitMessageHandler( logger.error("Error during message generation", e) CommitMessageService.Result.Error(e.message ?: "Unknown error") } - + // Set message on EDT ApplicationManager.getApplication().invokeLater({ isGenerating = false updateButtonState() - + when (result) { is CommitMessageService.Result.Success -> { logger.info("Successfully generated and set commit message: ${result.message}") @@ -225,7 +220,6 @@ class CommitMessageHandler( } } }, ModalityState.defaultModalityState()) - } catch (e: ProcessCanceledException) { logger.info("Commit message generation cancelled") ApplicationManager.getApplication().invokeLater({ @@ -253,7 +247,7 @@ class CommitMessageHandler( val dataContext = SimpleDataContext.builder() .add(Refreshable.PANEL_KEY, panel) .build() - + val discoveryResult = fileDiscoveryService.discoverFilesWithResult(panel.project, dataContext) when (discoveryResult) { is FileDiscoveryService.FileDiscoveryResult.Success -> discoveryResult.files diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt index c5a81ab05b..3026e1514f 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt @@ -1,15 +1,13 @@ package ai.kilocode.jetbrains.git +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project -import com.intellij.openapi.actionSystem.DataContext -import com.intellij.openapi.vcs.VcsDataKeys import com.intellij.openapi.vcs.CheckinProjectPanel -import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vcs.VcsDataKeys import com.intellij.openapi.vcs.changes.ChangeListManager import com.intellij.openapi.vcs.ui.Refreshable -import com.intellij.openapi.wm.ToolWindowManager /** * Service for discovering files to include in commit messages @@ -22,17 +20,17 @@ class FileDiscoveryService { */ fun discoverFiles(project: Project, dataContext: DataContext): List { logger.info("Starting file discovery for commit message generation") - + // Try different strategies in order of preference // 1. VcsDataKeys (contextual selection) - most specific // 2. CheckinProjectPanel (from commit dialog) // 3. ChangeListManager (fallback to all uncommitted changes) - + val result = tryVcsDataKeys(dataContext) ?: tryCheckinProjectPanel(dataContext) ?: tryChangeListManager(project) ?: emptyList() - + logger.info("File discovery completed: found ${result.size} files") return result } @@ -40,7 +38,7 @@ class FileDiscoveryService { private fun tryVcsDataKeys(dataContext: DataContext): List? { return try { logger.debug("[DIAGNOSTIC] Trying VcsDataKeys discovery...") - + // Try SELECTED_CHANGES first (user selection) val selectedChanges = VcsDataKeys.SELECTED_CHANGES.getData(dataContext) logger.debug("VcsDataKeys.SELECTED_CHANGES.getData() returned: ${selectedChanges?.size ?: "null"} changes") @@ -62,7 +60,7 @@ class FileDiscoveryService { return files } } - + logger.debug("[DIAGNOSTIC] VcsDataKeys: no changes found from either SELECTED_CHANGES or CHANGES") null } catch (e: Exception) { @@ -74,13 +72,13 @@ class FileDiscoveryService { private fun tryCheckinProjectPanel(dataContext: DataContext): List? { return try { logger.debug("[DIAGNOSTIC] Trying CheckinProjectPanel discovery...") - + // Try to get the panel from DataContext val panel = Refreshable.PANEL_KEY.getData(dataContext) as? CheckinProjectPanel logger.debug("Refreshable.PANEL_KEY.getData() returned: ${panel?.let { it::class.java.simpleName } ?: "null"}") if (panel != null) { logger.debug("[DIAGNOSTIC] Found CheckinProjectPanel") - + // Try to get selected changes val selectedChanges = try { panel.selectedChanges @@ -89,7 +87,7 @@ class FileDiscoveryService { null } logger.debug("CheckinProjectPanel.selectedChanges returned: ${selectedChanges?.size ?: "null"} changes") - + if (!selectedChanges.isNullOrEmpty()) { val files = selectedChanges.mapNotNull { it.virtualFile?.path } logger.debug("Mapped CheckinProjectPanel.selectedChanges to ${files.size} files") @@ -97,7 +95,7 @@ class FileDiscoveryService { return files } } - + logger.debug("[DIAGNOSTIC] CheckinProjectPanel exists but no selected changes found") } else { logger.debug("[DIAGNOSTIC] No CheckinProjectPanel in DataContext") @@ -112,10 +110,10 @@ class FileDiscoveryService { private fun tryChangeListManager(project: Project): List? { return try { logger.debug("[DIAGNOSTIC] Trying ChangeListManager discovery (fallback)...") - + val changeListManager = ChangeListManager.getInstance(project) logger.debug("Retrieved ChangeListManager instance") - + // Get all changes from all changelists val allChanges = changeListManager.allChanges logger.debug("ChangeListManager.allChanges returned: ${allChanges.size} changes") @@ -124,7 +122,7 @@ class FileDiscoveryService { logger.debug("Mapped ChangeListManager.allChanges to ${files.size} files") return files } - + logger.warn("[DIAGNOSTIC] ChangeListManager: no changes found in any changelist") null } catch (e: Exception) { @@ -167,4 +165,4 @@ class FileDiscoveryService { return ApplicationManager.getApplication().getService(FileDiscoveryService::class.java) } } -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt index ff4811d2bb..94120c3e95 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt @@ -8,10 +8,10 @@ object InlineCompletionConstants { * VSCode extension command ID for inline completion generation. */ const val EXTERNAL_COMMAND_ID = "kilo-code.jetbrains.getInlineCompletions" - + /** * Default timeout in milliseconds for inline completion requests. * Set to 10 seconds to allow sufficient time for LLM response. */ const val RPC_TIMEOUT_MS = 10000L -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt index 15bc10e1eb..0486886914 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt @@ -3,29 +3,28 @@ package ai.kilocode.jetbrains.inline import com.intellij.codeInsight.inline.completion.InlineCompletionProvider import com.intellij.openapi.Disposable import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project /** * Manages the lifecycle of inline completion providers. * Handles registration, unregistration, and document selector matching. - * + * * This class follows the same pattern as code actions registration, * maintaining a mapping of handles to providers. */ class InlineCompletionManager(private val project: Project) : Disposable { - + private val logger = Logger.getInstance(InlineCompletionManager::class.java) - + /** * Map of handle to provider instance. * Used to track and manage registered providers. */ private val providers = mutableMapOf() - + /** * Registers an inline completion provider. - * + * * @param handle Unique handle for this provider * @param selector Document selector (language patterns, file patterns, etc.) * @param supportsHandleDidShowCompletionItem Whether the provider supports showing completion items @@ -41,52 +40,52 @@ class InlineCompletionManager(private val project: Project) : Disposable { extensionId: String, yieldsToExtensionIds: List, displayName: String?, - debounceDelayMs: Int? + debounceDelayMs: Int?, ) { logger.info("Registering inline completion provider: handle=$handle, extensionId=$extensionId, displayName=$displayName") - + try { // Create the provider instance val provider = KiloCodeInlineCompletionProvider( handle = handle, project = project, extensionId = extensionId, - displayName = displayName + displayName = displayName, ) - + // Register with IntelliJ's inline completion system using extension point // Note: InlineCompletionProvider.EP_NAME is an application-level extension point, not project-level val epName = InlineCompletionProvider.EP_NAME val extensionPoint = epName.getPoint(null) - + // Add the provider to the extension point extensionPoint.registerExtension(provider, project) - + // Store the registration for later cleanup val providerRegistration = ProviderRegistration( provider = provider, selector = selector, extensionId = extensionId, - yieldsToExtensionIds = yieldsToExtensionIds + yieldsToExtensionIds = yieldsToExtensionIds, ) - + providers[handle] = providerRegistration - + logger.info("Successfully registered inline completion provider: handle=$handle") } catch (e: Exception) { logger.error("Failed to register inline completion provider: handle=$handle", e) throw e } } - + /** * Unregisters an inline completion provider. - * + * * @param handle The handle of the provider to unregister */ fun unregisterProvider(handle: Int) { logger.info("Unregistering inline completion provider: handle=$handle") - + val registration = providers.remove(handle) if (registration != null) { try { @@ -94,7 +93,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { val epName = InlineCompletionProvider.EP_NAME val extensionPoint = epName.getPoint(null) extensionPoint.unregisterExtension(registration.provider) - + logger.info("Successfully unregistered inline completion provider: handle=$handle") } catch (e: Exception) { logger.error("Error unregistering inline completion provider: handle=$handle", e) @@ -103,20 +102,20 @@ class InlineCompletionManager(private val project: Project) : Disposable { logger.warn("Attempted to unregister unknown provider: handle=$handle") } } - + /** * Gets a provider by its handle. - * + * * @param handle The handle of the provider * @return The provider instance, or null if not found */ fun getProvider(handle: Int): KiloCodeInlineCompletionProvider? { return providers[handle]?.provider } - + /** * Checks if a document matches the selector for a given provider. - * + * * @param handle The handle of the provider * @param languageId The language ID of the document * @param fileName The file name of the document @@ -124,17 +123,17 @@ class InlineCompletionManager(private val project: Project) : Disposable { */ fun matchesSelector(handle: Int, languageId: String?, fileName: String?): Boolean { val registration = providers[handle] ?: return false - + // Check each selector pattern for (selectorItem in registration.selector) { if (matchesSelectorItem(selectorItem, languageId, fileName)) { return true } } - + return false } - + /** * Checks if a document matches a single selector item. * Selector items can contain: @@ -145,7 +144,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { private fun matchesSelectorItem( selectorItem: Map, languageId: String?, - fileName: String? + fileName: String?, ): Boolean { // Check language pattern val language = selectorItem["language"] as? String @@ -154,7 +153,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { return false } } - + // Check file pattern val pattern = selectorItem["pattern"] as? String if (pattern != null && pattern != "**/*") { @@ -162,7 +161,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { return false } } - + // Check scheme (usually "file" for local files) val scheme = selectorItem["scheme"] as? String if (scheme != null && scheme != "*") { @@ -171,32 +170,32 @@ class InlineCompletionManager(private val project: Project) : Disposable { return false } } - + return true } - + /** * Simple pattern matching (supports * wildcard). */ private fun matchesPattern(value: String, pattern: String): Boolean { if (pattern == "*") return true if (pattern == value) return true - + // Convert glob pattern to regex val regex = pattern .replace(".", "\\.") .replace("*", ".*") .toRegex() - + return regex.matches(value) } - + /** * Glob pattern matching for file paths. */ private fun matchesGlobPattern(fileName: String, pattern: String): Boolean { if (pattern == "**/*") return true - + // Convert glob pattern to regex val regex = pattern .replace(".", "\\.") @@ -204,23 +203,23 @@ class InlineCompletionManager(private val project: Project) : Disposable { .replace("*", "[^/]*") .replace("?", ".") .toRegex() - + return regex.matches(fileName) } - + /** * Disposes all registered providers. */ override fun dispose() { logger.info("Disposing InlineCompletionManager, unregistering ${providers.size} providers") - + // Unregister all providers val handles = providers.keys.toList() for (handle in handles) { unregisterProvider(handle) } } - + /** * Internal class to track provider registrations. */ @@ -228,6 +227,6 @@ class InlineCompletionManager(private val project: Project) : Disposable { val provider: KiloCodeInlineCompletionProvider, val selector: List>, val extensionId: String, - val yieldsToExtensionIds: List + val yieldsToExtensionIds: List, ) -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt index 26e2daaf45..e0c54999a3 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt @@ -21,7 +21,7 @@ import java.util.concurrent.atomic.AtomicReference */ class InlineCompletionService { private val logger: Logger = Logger.getInstance(InlineCompletionService::class.java) - + /** * Tracks the current request ID to validate responses. * Only completions matching the current request ID will be shown. @@ -41,7 +41,7 @@ class InlineCompletionService { */ data class CompletionItem( val insertText: String, - val range: Range? + val range: Range?, ) /** @@ -49,7 +49,7 @@ class InlineCompletionService { */ data class Range( val start: Position, - val end: Position + val end: Position, ) /** @@ -57,7 +57,7 @@ class InlineCompletionService { */ data class Position( val line: Int, - val character: Int + val character: Int, ) /** @@ -76,7 +76,7 @@ class InlineCompletionService { document: Document, line: Int, character: Int, - languageId: String + languageId: String, ): Result { return try { val proxy = getRPCProxy(project) @@ -106,7 +106,8 @@ class InlineCompletionService { // Check if this is a wrapped cancellation exception if (e.cause is kotlinx.coroutines.CancellationException || e.cause is java.util.concurrent.CancellationException || - e.message?.contains("cancelled", ignoreCase = true) == true) { + e.message?.contains("cancelled", ignoreCase = true) == true + ) { logger.debug("Inline completion cancelled (wrapped exception): ${e.message}") return Result.Success(emptyList()) } @@ -135,25 +136,25 @@ class InlineCompletionService { line: Int, character: Int, languageId: String, - requestId: String + requestId: String, ): Any? { // Get full file content val fileContent = document.text - + // Get the actual file path from the document val virtualFile = FileDocumentManager.getInstance().getFile(document) val documentUri = virtualFile?.path?.let { "file://$it" } ?: "file://jetbrains-document" - + // Prepare arguments for RPC call including request ID val args = listOf( documentUri, mapOf( "line" to line, - "character" to character + "character" to character, ), fileContent, languageId, - requestId + requestId, ) val promise: LazyPromise = proxy.executeContributedCommand( @@ -165,7 +166,7 @@ class InlineCompletionService { val result = withTimeout(InlineCompletionConstants.RPC_TIMEOUT_MS) { promise.await() } - + return result } @@ -191,7 +192,7 @@ class InlineCompletionService { logger.info("Discarding stale completion: response requestId=$responseRequestId, current=$current") return Result.Success(emptyList()) } - + // Handle error response if (error != null) { logger.warn("Inline completion failed with error: $error") @@ -220,14 +221,16 @@ class InlineCompletionService { Range( Position( (start["line"] as? Number)?.toInt() ?: 0, - (start["character"] as? Number)?.toInt() ?: 0 + (start["character"] as? Number)?.toInt() ?: 0, ), Position( (end["line"] as? Number)?.toInt() ?: 0, - (end["character"] as? Number)?.toInt() ?: 0 - ) + (end["character"] as? Number)?.toInt() ?: 0, + ), ) - } else null + } else { + null + } } CompletionItem(insertText, range) } else { @@ -248,4 +251,4 @@ class InlineCompletionService { return ApplicationManager.getApplication().getService(InlineCompletionService::class.java) } } -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt new file mode 100644 index 0000000000..45d24f68eb --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package ai.kilocode.jetbrains.inline + +import ai.kilocode.jetbrains.core.PluginContext +import ai.kilocode.jetbrains.core.ServiceProxyRegistry +import ai.kilocode.jetbrains.ipc.proxy.interfaces.ExtHostCommandsProxy +import com.intellij.codeInsight.inline.completion.DefaultInlineCompletionInsertHandler +import com.intellij.codeInsight.inline.completion.InlineCompletionInsertEnvironment +import com.intellij.codeInsight.inline.completion.elements.InlineCompletionElement +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project + +/** + * Custom insert handler that triggers telemetry when inline completions are accepted. + * Extends DefaultInlineCompletionInsertHandler to maintain default insertion behavior + * while adding telemetry tracking via RPC to the VSCode extension. + */ +class KiloCodeInlineCompletionInsertHandler( + private val project: Project, +) : DefaultInlineCompletionInsertHandler() { + + private val logger = Logger.getInstance(KiloCodeInlineCompletionInsertHandler::class.java) + + companion object { + /** + * Command ID registered in the VSCode extension for tracking acceptance events. + * This matches the command registered in GhostInlineCompletionProvider. + */ + private const val INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.ghost.inline-completion.accepted" + } + + /** + * Called after the completion text has been inserted into the document. + * This is our hook to trigger telemetry tracking. + * + * @param environment Contains information about the insertion context + * @param elements The inline completion elements that were inserted + */ + override fun afterInsertion( + environment: InlineCompletionInsertEnvironment, + elements: List, + ) { + // Note: NOT calling super.afterInsertion() to avoid potential duplicate telemetry + // The default implementation may be empty or may trigger its own telemetry + + // Trigger telemetry via RPC + try { + val proxy = getRPCProxy() + if (proxy != null) { + // Execute the acceptance command asynchronously + // No need to wait for the result as this is fire-and-forget telemetry + proxy.executeContributedCommand( + INLINE_COMPLETION_ACCEPTED_COMMAND, + emptyList(), + ) + logger.debug("Triggered inline completion acceptance telemetry") + } else { + logger.warn("Failed to trigger acceptance telemetry - RPC proxy not available") + } + } catch (e: Exception) { + // Don't let telemetry errors affect the user experience + logger.warn("Error triggering acceptance telemetry", e) + } + } + + /** + * Gets the RPC proxy for command execution from the project's PluginContext. + */ + private fun getRPCProxy(): ExtHostCommandsProxy? { + return project.getService(PluginContext::class.java) + ?.getRPCProtocol() + ?.getProxy(ServiceProxyRegistry.ExtHostContext.ExtHostCommands) + } +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt index 5a2deddfae..1e04e5b048 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt @@ -6,7 +6,6 @@ import com.intellij.codeInsight.inline.completion.InlineCompletionProviderID import com.intellij.codeInsight.inline.completion.InlineCompletionRequest import com.intellij.codeInsight.inline.completion.elements.InlineCompletionGrayTextElement import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionSingleSuggestion -import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionSuggestion import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileDocumentManager @@ -24,18 +23,24 @@ class KiloCodeInlineCompletionProvider( private val handle: Int, private val project: Project, private val extensionId: String, - private val displayName: String? + private val displayName: String?, ) : InlineCompletionProvider { - + private val logger = Logger.getInstance(KiloCodeInlineCompletionProvider::class.java) private val completionService = InlineCompletionService.getInstance() - + + /** + * Custom insert handler that triggers telemetry when completions are accepted. + * Overrides the default insertHandler from InlineCompletionProvider. + */ + override val insertHandler = KiloCodeInlineCompletionInsertHandler(project) + /** * Unique identifier for this provider. * Required by InlineCompletionProvider interface. */ override val id: InlineCompletionProviderID = InlineCompletionProviderID("kilocode-inline-completion-$extensionId-$handle") - + /** * Gets inline completion suggestions using the Ghost service. * Sends full file content to ensure accurate completions. @@ -47,37 +52,37 @@ class KiloCodeInlineCompletionProvider( val positionInfo = ReadAction.compute { val editor = request.editor val document = editor.document - + // Use request.endOffset which is the correct insertion point for the completion // This is where IntelliJ expects the completion to be inserted val completionOffset = request.endOffset - + // Calculate line and character position from the completion offset val line = document.getLineNumber(completionOffset) val lineStartOffset = document.getLineStartOffset(line) val char = completionOffset - lineStartOffset - + // Get language ID from file type val virtualFile = FileDocumentManager.getInstance().getFile(document) val langId = virtualFile?.fileType?.name?.lowercase() ?: "text" - + // Also get caret position for logging/debugging val caretOffset = editor.caretModel.offset - + PositionInfo(completionOffset, line, char, langId, document, caretOffset) } - + val (offset, lineNumber, character, languageId, document, caretOffset) = positionInfo - + // Call the new service with full file content val result = completionService.getInlineCompletions( project, document, lineNumber, character, - languageId + languageId, ) - + // Convert result to InlineCompletionSingleSuggestion using the new API return when (result) { is InlineCompletionService.Result.Success -> { @@ -104,14 +109,15 @@ class KiloCodeInlineCompletionProvider( } catch (e: Exception) { // Check if this is a wrapped cancellation if (e.cause is kotlinx.coroutines.CancellationException || - e.cause is java.util.concurrent.CancellationException) { + e.cause is java.util.concurrent.CancellationException + ) { return InlineCompletionSingleSuggestion.build { } } // Real error - log appropriately return InlineCompletionSingleSuggestion.build { } } } - + /** * Determines if this provider is enabled for the given event. * Document selector matching is handled during registration. @@ -119,7 +125,7 @@ class KiloCodeInlineCompletionProvider( override fun isEnabled(event: InlineCompletionEvent): Boolean { return true } - + /** * Data class to hold position information calculated in read action */ @@ -129,6 +135,6 @@ class KiloCodeInlineCompletionProvider( val character: Int, val languageId: String, val document: com.intellij.openapi.editor.Document, - val caretOffset: Int + val caretOffset: Int, ) -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6221eec745..c724d31f1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4973,7 +4973,6 @@ packages: '@lancedb/lancedb@0.21.3': resolution: {integrity: sha512-hfzp498BfcCJ730fV1YGGoXVxRgE+W1n0D0KwanKlbt8bBPSQ6E6Tf8mPXc8rKdAXIRR3o5mTzMG3z3Fda+m3Q==} engines: {node: '>= 18'} - cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' @@ -27998,7 +27997,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.4)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.4)(yaml@2.8.1) '@vitest/utils@2.0.5': dependencies: diff --git a/src/services/ghost/GhostServiceManager.ts b/src/services/ghost/GhostServiceManager.ts index 3aa547399d..decb07d20e 100644 --- a/src/services/ghost/GhostServiceManager.ts +++ b/src/services/ghost/GhostServiceManager.ts @@ -49,14 +49,13 @@ export class GhostServiceManager { // Register the providers this.codeActionProvider = new GhostCodeActionProvider() - const { kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() this.inlineCompletionProvider = new GhostInlineCompletionProvider( this.context, this.model, this.updateCostTracking.bind(this), () => this.settings, this.cline, - !kiloCodeWrapperJetbrains ? new AutocompleteTelemetry() : null, + new AutocompleteTelemetry(), ) void this.load() From 4ee5f0c809f29983fd1d446391a31f837b26c44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 19 Dec 2025 13:41:25 -0300 Subject: [PATCH 3/5] refactor: remove prettier changes --- .../actions/GitCommitMessageAction.kt | 17 ++-- .../actors/MainThreadLanguageFeaturesShape.kt | 10 +-- .../jetbrains/commands/SetContextCommands.kt | 26 +++--- .../kilocode/jetbrains/core/ContextManager.kt | 40 ++++----- .../jetbrains/git/CommitMessageHandler.kt | 22 +++-- .../jetbrains/git/FileDiscoveryService.kt | 32 ++++---- .../inline/InlineCompletionManager.kt | 81 ++++++++++--------- .../inline/InlineCompletionService.kt | 39 +++++---- 8 files changed, 135 insertions(+), 132 deletions(-) diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt index 86a6327be3..c3332e6a41 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actions/GitCommitMessageAction.kt @@ -6,8 +6,8 @@ package ai.kilocode.jetbrains.actions import ai.kilocode.jetbrains.git.CommitMessageService -import ai.kilocode.jetbrains.git.FileDiscoveryService import ai.kilocode.jetbrains.git.WorkspaceResolver +import ai.kilocode.jetbrains.git.FileDiscoveryService import ai.kilocode.jetbrains.i18n.I18n import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction @@ -85,7 +85,7 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera project: Project, commitControl: CommitMessageUi, workspacePath: String, - dataContext: DataContext, + dataContext: DataContext ) { ProgressManager.getInstance().run(object : Task.Backgroundable( project, @@ -128,10 +128,8 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera ApplicationManager.getApplication().invokeLater { Messages.showErrorDialog( project, - I18n.t( - "kilocode:commitMessage.errors.processingError", - mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown"))), - ), + I18n.t("kilocode:commitMessage.errors.processingError", + mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown")))), I18n.t("kilocode:commitMessage.dialogs.error"), ) } @@ -182,10 +180,8 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera ApplicationManager.getApplication().invokeLater { Messages.showErrorDialog( project, - I18n.t( - "kilocode:commitMessage.errors.processingError", - mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown"))), - ), + I18n.t("kilocode:commitMessage.errors.processingError", + mapOf("error" to (e.message ?: I18n.t("kilocode:commitMessage.error.unknown")))), I18n.t("kilocode:commitMessage.dialogs.error"), ) } @@ -214,6 +210,7 @@ class GitCommitMessageAction : AnAction(I18n.t("kilocode:commitMessage.ui.genera } } + companion object { val PENDING_COMMIT_MESSAGE_KEY = com.intellij.openapi.util.Key.create("KILOCODE_PENDING_COMMIT_MESSAGE") } diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt index f37bf35425..f4a8ebba31 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt @@ -448,7 +448,7 @@ interface MainThreadLanguageFeaturesShape : Disposable { */ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLanguageFeaturesShape { private val logger = Logger.getInstance(MainThreadLanguageFeatures::class.java) - + /** * Manager for inline completion providers. * Handles registration, unregistration, and lifecycle management. @@ -459,7 +459,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu override fun unregister(handle: Int) { logger.info("Unregistering service: handle=$handle") - + // Try to unregister from inline completion manager try { inlineCompletionManager.unregisterProvider(handle) @@ -628,7 +628,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu debounceDelayMs: Int?, ) { logger.info("Registering inline completions support: handle=$handle, extensionId=$extensionId, displayName=$displayName") - + try { inlineCompletionManager.registerProvider( handle = handle, @@ -637,7 +637,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu extensionId = extensionId, yieldsToExtensionIds = yieldsToExtensionIds, displayName = displayName, - debounceDelayMs = debounceDelayMs, + debounceDelayMs = debounceDelayMs ) logger.info("Successfully registered inline completion provider: handle=$handle") } catch (e: Exception) { @@ -737,7 +737,7 @@ class MainThreadLanguageFeatures(private val project: Project) : MainThreadLangu override fun dispose() { logger.info("Disposing MainThreadLanguageFeatures resources") - + // Dispose inline completion manager try { inlineCompletionManager.dispose() diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt index c6bfdfa9b2..d137179787 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt @@ -6,11 +6,11 @@ import com.intellij.openapi.project.Project /** * Registers the setContext command for managing VSCode-style context keys. - * + * * The setContext command allows the extension to set context values that can be used * to control UI state and feature availability. This is commonly used by features like * the GhostProvider (autocomplete) to enable/disable keybindings dynamically. - * + * * @param project The current IntelliJ project * @param registry The command registry to register commands with */ @@ -21,32 +21,32 @@ fun registerSetContextCommands(project: Project, registry: CommandRegistry) { override fun getId(): String { return "setContext" } - + override fun getMethod(): String { return "setContext" } - + override fun handler(): Any { return SetContextCommands(project) } - + override fun returns(): String? { return "void" } }, ) - + // Register alias with underscore prefix for compatibility with VSCode registry.registerCommandAlias("setContext", "_setContext") } /** * Handles setContext command operations for managing context keys. - * + * * This class provides the implementation for the setContext command, which allows * setting context key-value pairs that can be used throughout the plugin to control * feature availability and UI state. - * + * * Example context keys used by GhostProvider: * - kilocode.ghost.enableQuickInlineTaskKeybinding * - kilocode.ghost.enableSmartInlineTaskKeybinding @@ -54,13 +54,13 @@ fun registerSetContextCommands(project: Project, registry: CommandRegistry) { class SetContextCommands(val project: Project) { private val logger = Logger.getInstance(SetContextCommands::class.java) private val contextManager = ContextManager.getInstance(project) - + /** * Sets a context value for the given key. - * + * * This method is called when the setContext command is executed from the extension. * It stores the key-value pair in the ContextManager for later retrieval. - * + * * @param key The context key to set (e.g., "kilocode.ghost.enableQuickInlineTaskKeybinding") * @param value The value to set (typically Boolean, but can be String, Number, etc.) * @return null (void return type) @@ -74,7 +74,7 @@ class SetContextCommands(val project: Project) { logger.error("Failed to set context: $key = $value", e) throw e } - + return null } -} +} \ No newline at end of file diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt index 1b2e38ff49..d6a3d94a10 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt @@ -7,13 +7,13 @@ import java.util.concurrent.ConcurrentHashMap /** * Context Manager Service - * + * * Manages VSCode-style context keys for the JetBrains plugin. * Provides thread-safe storage for context key-value pairs that can be used * to control UI state and feature availability. - * + * * This service is project-scoped, meaning each project has its own context storage. - * + * * Example usage: * ```kotlin * val contextManager = project.getService(ContextManager::class.java) @@ -24,16 +24,16 @@ import java.util.concurrent.ConcurrentHashMap @Service(Service.Level.PROJECT) class ContextManager { private val logger = Logger.getInstance(ContextManager::class.java) - + /** * Thread-safe storage for context key-value pairs */ private val contexts = ConcurrentHashMap() - + /** * Sets a context value for the given key. * If the value is null, the context key will be removed. - * + * * @param key The context key (e.g., "kilocode.ghost.enableQuickInlineTaskKeybinding") * @param value The value to set (can be Boolean, String, Number, or any serializable type) */ @@ -42,9 +42,9 @@ class ContextManager { removeContext(key) return } - + val previousValue = contexts.put(key, value) - + if (logger.isDebugEnabled) { if (previousValue != null) { logger.debug("Context updated: $key = $value (previous: $previousValue)") @@ -53,30 +53,30 @@ class ContextManager { } } } - + /** * Gets the context value for the given key. - * + * * @param key The context key to retrieve * @return The context value, or null if the key doesn't exist */ fun getContext(key: String): Any? { return contexts[key] } - + /** * Checks if a context key exists. - * + * * @param key The context key to check * @return true if the key exists, false otherwise */ fun hasContext(key: String): Boolean { return contexts.containsKey(key) } - + /** * Removes a context key and its value. - * + * * @param key The context key to remove */ fun removeContext(key: String) { @@ -85,17 +85,17 @@ class ContextManager { logger.debug("Context removed: $key (previous value: $previousValue)") } } - + /** * Gets all context keys and their values. * Returns a copy of the context map to prevent external modification. - * + * * @return A map of all context keys and values */ fun getAllContexts(): Map { return contexts.toMap() } - + /** * Clears all context keys. * This is typically used during cleanup or reset operations. @@ -107,11 +107,11 @@ class ContextManager { logger.debug("Cleared all contexts ($count keys)") } } - + companion object { /** * Gets the ContextManager instance for the given project. - * + * * @param project The project to get the ContextManager for * @return The ContextManager instance */ @@ -119,4 +119,4 @@ class ContextManager { return project.getService(ContextManager::class.java) } } -} +} \ No newline at end of file diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt index fffbb3d6ad..ded4bc8ec7 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageHandler.kt @@ -6,9 +6,9 @@ package ai.kilocode.jetbrains.git import ai.kilocode.jetbrains.actions.GitCommitMessageAction import ai.kilocode.jetbrains.i18n.I18n -import com.intellij.openapi.actionSystem.impl.SimpleDataContext import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressIndicator @@ -16,15 +16,20 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.impl.SimpleDataContext import com.intellij.openapi.vcs.CheckinProjectPanel import com.intellij.openapi.vcs.changes.CommitContext import com.intellij.openapi.vcs.checkin.CheckinHandler import com.intellij.openapi.vcs.checkin.CheckinHandler.ReturnResult -import com.intellij.openapi.vcs.ui.Refreshable import com.intellij.openapi.vcs.ui.RefreshableOnComponent +import com.intellij.openapi.vcs.ui.Refreshable import com.intellij.util.ui.FormBuilder import kotlinx.coroutines.* import java.awt.BorderLayout +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import javax.swing.JButton import javax.swing.JPanel @@ -173,7 +178,7 @@ class CommitMessageHandler( ) { try { indicator.text = I18n.t("kilocode:commitMessage.progress.connecting") - + val files = try { runBlocking { withTimeout(5000) { @@ -187,9 +192,9 @@ class CommitMessageHandler( logger.warn("Error getting selected files: ${e.message}") emptyList() } - + indicator.text = I18n.t("kilocode:commitMessage.progress.generating") - + // Generate message on background thread val result = try { runBlocking { @@ -199,12 +204,12 @@ class CommitMessageHandler( logger.error("Error during message generation", e) CommitMessageService.Result.Error(e.message ?: "Unknown error") } - + // Set message on EDT ApplicationManager.getApplication().invokeLater({ isGenerating = false updateButtonState() - + when (result) { is CommitMessageService.Result.Success -> { logger.info("Successfully generated and set commit message: ${result.message}") @@ -220,6 +225,7 @@ class CommitMessageHandler( } } }, ModalityState.defaultModalityState()) + } catch (e: ProcessCanceledException) { logger.info("Commit message generation cancelled") ApplicationManager.getApplication().invokeLater({ @@ -247,7 +253,7 @@ class CommitMessageHandler( val dataContext = SimpleDataContext.builder() .add(Refreshable.PANEL_KEY, panel) .build() - + val discoveryResult = fileDiscoveryService.discoverFilesWithResult(panel.project, dataContext) when (discoveryResult) { is FileDiscoveryService.FileDiscoveryResult.Success -> discoveryResult.files diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt index 3026e1514f..c5a81ab05b 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/FileDiscoveryService.kt @@ -1,13 +1,15 @@ package ai.kilocode.jetbrains.git -import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project -import com.intellij.openapi.vcs.CheckinProjectPanel +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.vcs.VcsDataKeys +import com.intellij.openapi.vcs.CheckinProjectPanel +import com.intellij.openapi.vcs.changes.Change import com.intellij.openapi.vcs.changes.ChangeListManager import com.intellij.openapi.vcs.ui.Refreshable +import com.intellij.openapi.wm.ToolWindowManager /** * Service for discovering files to include in commit messages @@ -20,17 +22,17 @@ class FileDiscoveryService { */ fun discoverFiles(project: Project, dataContext: DataContext): List { logger.info("Starting file discovery for commit message generation") - + // Try different strategies in order of preference // 1. VcsDataKeys (contextual selection) - most specific // 2. CheckinProjectPanel (from commit dialog) // 3. ChangeListManager (fallback to all uncommitted changes) - + val result = tryVcsDataKeys(dataContext) ?: tryCheckinProjectPanel(dataContext) ?: tryChangeListManager(project) ?: emptyList() - + logger.info("File discovery completed: found ${result.size} files") return result } @@ -38,7 +40,7 @@ class FileDiscoveryService { private fun tryVcsDataKeys(dataContext: DataContext): List? { return try { logger.debug("[DIAGNOSTIC] Trying VcsDataKeys discovery...") - + // Try SELECTED_CHANGES first (user selection) val selectedChanges = VcsDataKeys.SELECTED_CHANGES.getData(dataContext) logger.debug("VcsDataKeys.SELECTED_CHANGES.getData() returned: ${selectedChanges?.size ?: "null"} changes") @@ -60,7 +62,7 @@ class FileDiscoveryService { return files } } - + logger.debug("[DIAGNOSTIC] VcsDataKeys: no changes found from either SELECTED_CHANGES or CHANGES") null } catch (e: Exception) { @@ -72,13 +74,13 @@ class FileDiscoveryService { private fun tryCheckinProjectPanel(dataContext: DataContext): List? { return try { logger.debug("[DIAGNOSTIC] Trying CheckinProjectPanel discovery...") - + // Try to get the panel from DataContext val panel = Refreshable.PANEL_KEY.getData(dataContext) as? CheckinProjectPanel logger.debug("Refreshable.PANEL_KEY.getData() returned: ${panel?.let { it::class.java.simpleName } ?: "null"}") if (panel != null) { logger.debug("[DIAGNOSTIC] Found CheckinProjectPanel") - + // Try to get selected changes val selectedChanges = try { panel.selectedChanges @@ -87,7 +89,7 @@ class FileDiscoveryService { null } logger.debug("CheckinProjectPanel.selectedChanges returned: ${selectedChanges?.size ?: "null"} changes") - + if (!selectedChanges.isNullOrEmpty()) { val files = selectedChanges.mapNotNull { it.virtualFile?.path } logger.debug("Mapped CheckinProjectPanel.selectedChanges to ${files.size} files") @@ -95,7 +97,7 @@ class FileDiscoveryService { return files } } - + logger.debug("[DIAGNOSTIC] CheckinProjectPanel exists but no selected changes found") } else { logger.debug("[DIAGNOSTIC] No CheckinProjectPanel in DataContext") @@ -110,10 +112,10 @@ class FileDiscoveryService { private fun tryChangeListManager(project: Project): List? { return try { logger.debug("[DIAGNOSTIC] Trying ChangeListManager discovery (fallback)...") - + val changeListManager = ChangeListManager.getInstance(project) logger.debug("Retrieved ChangeListManager instance") - + // Get all changes from all changelists val allChanges = changeListManager.allChanges logger.debug("ChangeListManager.allChanges returned: ${allChanges.size} changes") @@ -122,7 +124,7 @@ class FileDiscoveryService { logger.debug("Mapped ChangeListManager.allChanges to ${files.size} files") return files } - + logger.warn("[DIAGNOSTIC] ChangeListManager: no changes found in any changelist") null } catch (e: Exception) { @@ -165,4 +167,4 @@ class FileDiscoveryService { return ApplicationManager.getApplication().getService(FileDiscoveryService::class.java) } } -} +} \ No newline at end of file diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt index 0486886914..15bc10e1eb 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt @@ -3,28 +3,29 @@ package ai.kilocode.jetbrains.inline import com.intellij.codeInsight.inline.completion.InlineCompletionProvider import com.intellij.openapi.Disposable import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project /** * Manages the lifecycle of inline completion providers. * Handles registration, unregistration, and document selector matching. - * + * * This class follows the same pattern as code actions registration, * maintaining a mapping of handles to providers. */ class InlineCompletionManager(private val project: Project) : Disposable { - + private val logger = Logger.getInstance(InlineCompletionManager::class.java) - + /** * Map of handle to provider instance. * Used to track and manage registered providers. */ private val providers = mutableMapOf() - + /** * Registers an inline completion provider. - * + * * @param handle Unique handle for this provider * @param selector Document selector (language patterns, file patterns, etc.) * @param supportsHandleDidShowCompletionItem Whether the provider supports showing completion items @@ -40,52 +41,52 @@ class InlineCompletionManager(private val project: Project) : Disposable { extensionId: String, yieldsToExtensionIds: List, displayName: String?, - debounceDelayMs: Int?, + debounceDelayMs: Int? ) { logger.info("Registering inline completion provider: handle=$handle, extensionId=$extensionId, displayName=$displayName") - + try { // Create the provider instance val provider = KiloCodeInlineCompletionProvider( handle = handle, project = project, extensionId = extensionId, - displayName = displayName, + displayName = displayName ) - + // Register with IntelliJ's inline completion system using extension point // Note: InlineCompletionProvider.EP_NAME is an application-level extension point, not project-level val epName = InlineCompletionProvider.EP_NAME val extensionPoint = epName.getPoint(null) - + // Add the provider to the extension point extensionPoint.registerExtension(provider, project) - + // Store the registration for later cleanup val providerRegistration = ProviderRegistration( provider = provider, selector = selector, extensionId = extensionId, - yieldsToExtensionIds = yieldsToExtensionIds, + yieldsToExtensionIds = yieldsToExtensionIds ) - + providers[handle] = providerRegistration - + logger.info("Successfully registered inline completion provider: handle=$handle") } catch (e: Exception) { logger.error("Failed to register inline completion provider: handle=$handle", e) throw e } } - + /** * Unregisters an inline completion provider. - * + * * @param handle The handle of the provider to unregister */ fun unregisterProvider(handle: Int) { logger.info("Unregistering inline completion provider: handle=$handle") - + val registration = providers.remove(handle) if (registration != null) { try { @@ -93,7 +94,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { val epName = InlineCompletionProvider.EP_NAME val extensionPoint = epName.getPoint(null) extensionPoint.unregisterExtension(registration.provider) - + logger.info("Successfully unregistered inline completion provider: handle=$handle") } catch (e: Exception) { logger.error("Error unregistering inline completion provider: handle=$handle", e) @@ -102,20 +103,20 @@ class InlineCompletionManager(private val project: Project) : Disposable { logger.warn("Attempted to unregister unknown provider: handle=$handle") } } - + /** * Gets a provider by its handle. - * + * * @param handle The handle of the provider * @return The provider instance, or null if not found */ fun getProvider(handle: Int): KiloCodeInlineCompletionProvider? { return providers[handle]?.provider } - + /** * Checks if a document matches the selector for a given provider. - * + * * @param handle The handle of the provider * @param languageId The language ID of the document * @param fileName The file name of the document @@ -123,17 +124,17 @@ class InlineCompletionManager(private val project: Project) : Disposable { */ fun matchesSelector(handle: Int, languageId: String?, fileName: String?): Boolean { val registration = providers[handle] ?: return false - + // Check each selector pattern for (selectorItem in registration.selector) { if (matchesSelectorItem(selectorItem, languageId, fileName)) { return true } } - + return false } - + /** * Checks if a document matches a single selector item. * Selector items can contain: @@ -144,7 +145,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { private fun matchesSelectorItem( selectorItem: Map, languageId: String?, - fileName: String?, + fileName: String? ): Boolean { // Check language pattern val language = selectorItem["language"] as? String @@ -153,7 +154,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { return false } } - + // Check file pattern val pattern = selectorItem["pattern"] as? String if (pattern != null && pattern != "**/*") { @@ -161,7 +162,7 @@ class InlineCompletionManager(private val project: Project) : Disposable { return false } } - + // Check scheme (usually "file" for local files) val scheme = selectorItem["scheme"] as? String if (scheme != null && scheme != "*") { @@ -170,32 +171,32 @@ class InlineCompletionManager(private val project: Project) : Disposable { return false } } - + return true } - + /** * Simple pattern matching (supports * wildcard). */ private fun matchesPattern(value: String, pattern: String): Boolean { if (pattern == "*") return true if (pattern == value) return true - + // Convert glob pattern to regex val regex = pattern .replace(".", "\\.") .replace("*", ".*") .toRegex() - + return regex.matches(value) } - + /** * Glob pattern matching for file paths. */ private fun matchesGlobPattern(fileName: String, pattern: String): Boolean { if (pattern == "**/*") return true - + // Convert glob pattern to regex val regex = pattern .replace(".", "\\.") @@ -203,23 +204,23 @@ class InlineCompletionManager(private val project: Project) : Disposable { .replace("*", "[^/]*") .replace("?", ".") .toRegex() - + return regex.matches(fileName) } - + /** * Disposes all registered providers. */ override fun dispose() { logger.info("Disposing InlineCompletionManager, unregistering ${providers.size} providers") - + // Unregister all providers val handles = providers.keys.toList() for (handle in handles) { unregisterProvider(handle) } } - + /** * Internal class to track provider registrations. */ @@ -227,6 +228,6 @@ class InlineCompletionManager(private val project: Project) : Disposable { val provider: KiloCodeInlineCompletionProvider, val selector: List>, val extensionId: String, - val yieldsToExtensionIds: List, + val yieldsToExtensionIds: List ) -} +} \ No newline at end of file diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt index e0c54999a3..26e2daaf45 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt @@ -21,7 +21,7 @@ import java.util.concurrent.atomic.AtomicReference */ class InlineCompletionService { private val logger: Logger = Logger.getInstance(InlineCompletionService::class.java) - + /** * Tracks the current request ID to validate responses. * Only completions matching the current request ID will be shown. @@ -41,7 +41,7 @@ class InlineCompletionService { */ data class CompletionItem( val insertText: String, - val range: Range?, + val range: Range? ) /** @@ -49,7 +49,7 @@ class InlineCompletionService { */ data class Range( val start: Position, - val end: Position, + val end: Position ) /** @@ -57,7 +57,7 @@ class InlineCompletionService { */ data class Position( val line: Int, - val character: Int, + val character: Int ) /** @@ -76,7 +76,7 @@ class InlineCompletionService { document: Document, line: Int, character: Int, - languageId: String, + languageId: String ): Result { return try { val proxy = getRPCProxy(project) @@ -106,8 +106,7 @@ class InlineCompletionService { // Check if this is a wrapped cancellation exception if (e.cause is kotlinx.coroutines.CancellationException || e.cause is java.util.concurrent.CancellationException || - e.message?.contains("cancelled", ignoreCase = true) == true - ) { + e.message?.contains("cancelled", ignoreCase = true) == true) { logger.debug("Inline completion cancelled (wrapped exception): ${e.message}") return Result.Success(emptyList()) } @@ -136,25 +135,25 @@ class InlineCompletionService { line: Int, character: Int, languageId: String, - requestId: String, + requestId: String ): Any? { // Get full file content val fileContent = document.text - + // Get the actual file path from the document val virtualFile = FileDocumentManager.getInstance().getFile(document) val documentUri = virtualFile?.path?.let { "file://$it" } ?: "file://jetbrains-document" - + // Prepare arguments for RPC call including request ID val args = listOf( documentUri, mapOf( "line" to line, - "character" to character, + "character" to character ), fileContent, languageId, - requestId, + requestId ) val promise: LazyPromise = proxy.executeContributedCommand( @@ -166,7 +165,7 @@ class InlineCompletionService { val result = withTimeout(InlineCompletionConstants.RPC_TIMEOUT_MS) { promise.await() } - + return result } @@ -192,7 +191,7 @@ class InlineCompletionService { logger.info("Discarding stale completion: response requestId=$responseRequestId, current=$current") return Result.Success(emptyList()) } - + // Handle error response if (error != null) { logger.warn("Inline completion failed with error: $error") @@ -221,16 +220,14 @@ class InlineCompletionService { Range( Position( (start["line"] as? Number)?.toInt() ?: 0, - (start["character"] as? Number)?.toInt() ?: 0, + (start["character"] as? Number)?.toInt() ?: 0 ), Position( (end["line"] as? Number)?.toInt() ?: 0, - (end["character"] as? Number)?.toInt() ?: 0, - ), + (end["character"] as? Number)?.toInt() ?: 0 + ) ) - } else { - null - } + } else null } CompletionItem(insertText, range) } else { @@ -251,4 +248,4 @@ class InlineCompletionService { return ApplicationManager.getApplication().getService(InlineCompletionService::class.java) } } -} +} \ No newline at end of file From 7caa96e877669da77a8ba75a70cf0181642e9f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 19 Dec 2025 13:44:13 -0300 Subject: [PATCH 4/5] refactor: move command to constant file --- .../kilocode/jetbrains/git/CommitMessageConstants.kt | 2 +- .../jetbrains/inline/InlineCompletionConstants.kt | 6 ++++++ .../inline/KiloCodeInlineCompletionInsertHandler.kt | 10 +--------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt index f15a832121..a2cd2f4095 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/git/CommitMessageConstants.kt @@ -12,7 +12,7 @@ object CommitMessageConstants { * VSCode extension command ID for external commit message generation. */ const val EXTERNAL_COMMAND_ID = "kilo-code.jetbrains.generateCommitMessage" - + /** * Default timeout in milliseconds for commit message generation requests. */ diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt index 94120c3e95..ee587e002a 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt @@ -9,6 +9,12 @@ object InlineCompletionConstants { */ const val EXTERNAL_COMMAND_ID = "kilo-code.jetbrains.getInlineCompletions" + /** + * Command ID registered in the VSCode extension for tracking acceptance events. + * This matches the command registered in GhostInlineCompletionProvider. + */ + const val INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.ghost.inline-completion.accepted" + /** * Default timeout in milliseconds for inline completion requests. * Set to 10 seconds to allow sufficient time for LLM response. diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt index 45d24f68eb..df25e6654f 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt @@ -24,14 +24,6 @@ class KiloCodeInlineCompletionInsertHandler( private val logger = Logger.getInstance(KiloCodeInlineCompletionInsertHandler::class.java) - companion object { - /** - * Command ID registered in the VSCode extension for tracking acceptance events. - * This matches the command registered in GhostInlineCompletionProvider. - */ - private const val INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.ghost.inline-completion.accepted" - } - /** * Called after the completion text has been inserted into the document. * This is our hook to trigger telemetry tracking. @@ -53,7 +45,7 @@ class KiloCodeInlineCompletionInsertHandler( // Execute the acceptance command asynchronously // No need to wait for the result as this is fire-and-forget telemetry proxy.executeContributedCommand( - INLINE_COMPLETION_ACCEPTED_COMMAND, + InlineCompletionConstants.INLINE_COMPLETION_ACCEPTED_COMMAND, emptyList(), ) logger.debug("Triggered inline completion acceptance telemetry") From 52d42da4ddaf14e60f2365c535b4873a8d3719f4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 19 Dec 2025 21:24:07 +0100 Subject: [PATCH 5/5] fix: resolve Windows CLI spawn ENOENT error in Agent Manager (#4584) * fix: resolve Windows CLI spawn ENOENT error in Agent Manager Replace shell command-based CLI detection with filesystem-based executable resolution using PATHEXT environment variable. * fix: address PR review comments - Use error.code instead of error.message for EACCES detection - Rename fileExistsAsFile to pathExistsAsFile - Remove redundant isSymbolicLink check (stat follows symlinks) - Add clarifying comment about symlink behavior * chore: restore slackbot.md to match main * fix: add missing getPlatformDiagnostics mock in AgentManagerProvider tests * fix: use platform-appropriate paths in Windows tests * fix: separate Windows simulation tests from native Windows tests - Skip platform-switching tests when already on target platform - Add dedicated native Windows tests that run only on Windows CI - Add proper lstat mock to fs mocks (code uses both stat and lstat) - Use proper error codes in mock rejections * fix: remove platform simulation tests, keep only native platform tests Platform simulation (mocking process.platform) is fragile and doesn't truly test platform-specific behavior. Instead: - Windows tests run only on Windows CI (skipped elsewhere) - Non-Windows tests run only on non-Windows (skipped on Windows) - Let actual CI environments test their native platform behavior * fix: simplify tests by removing fragile Windows integration test The Windows .cmd shell:true behavior is already tested in CliProcessHandler. The PATHEXT resolution is tested in CliPathResolver.spec.ts. Production code works on Windows (confirmed), so remove complex integration test. * fix: skip Unix path tests on Windows Unix-style paths like /usr/bin/kilocode are not absolute on Windows (Windows requires drive letters like C:\). Skip these tests on Windows since the Windows-specific behavior is already tested by the PATHEXT tests. * fix: mock CliInstaller in tests to work on Windows On Windows, login shell is skipped and findExecutable uses fs.promises.stat instead of execSync. The tests were relying on execSync returning MOCK_CLI_PATH, which doesn't work on Windows. Fix: Mock getLocalCliPath() to return MOCK_CLI_PATH and make fileExistsAtPath return true for that path. This ensures findKilocodeCli finds the CLI via the local path check on all platforms. --- .../kilocode/agent-manager/CliPathResolver.ts | 156 +++++++++----- .../agent-manager/CliProcessHandler.ts | 17 ++ .../__tests__/AgentManagerProvider.spec.ts | 88 ++------ .../__tests__/CliPathResolver.spec.ts | 199 ++++++++++++++++-- src/core/kilocode/agent-manager/telemetry.ts | 4 + 5 files changed, 328 insertions(+), 136 deletions(-) diff --git a/src/core/kilocode/agent-manager/CliPathResolver.ts b/src/core/kilocode/agent-manager/CliPathResolver.ts index e161ef40fe..eebcdf2a51 100644 --- a/src/core/kilocode/agent-manager/CliPathResolver.ts +++ b/src/core/kilocode/agent-manager/CliPathResolver.ts @@ -4,6 +4,97 @@ import { execSync } from "node:child_process" import { fileExistsAtPath } from "../../../utils/fs" import { getLocalCliPath } from "./CliInstaller" +/** + * Case-insensitive lookup for environment variables. + * Windows environment variables can have inconsistent casing (PATH, Path, path). + */ +function getCaseInsensitive(target: NodeJS.ProcessEnv, key: string): string | undefined { + const lowercaseKey = key.toLowerCase() + const equivalentKey = Object.keys(target).find((k) => k.toLowerCase() === lowercaseKey) + return equivalentKey ? target[equivalentKey] : target[key] +} + +/** + * Check if a path exists and is a file (not a directory). + * Follows symlinks - a symlink to a file returns true, symlink to a directory returns false. + */ +async function pathExistsAsFile(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath) + return stat.isFile() + } catch (e: unknown) { + if (e instanceof Error && "code" in e && e.code === "EACCES") { + try { + const lstat = await fs.promises.lstat(filePath) + return lstat.isFile() || lstat.isSymbolicLink() + } catch { + return false + } + } + return false + } +} + +/** + * Find an executable by name, resolving it against PATH and PATHEXT (on Windows). + */ +export async function findExecutable( + command: string, + cwd?: string, + paths?: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (path.isAbsolute(command)) { + return (await pathExistsAsFile(command)) ? command : undefined + } + + if (cwd === undefined) { + cwd = process.cwd() + } + + const dir = path.dirname(command) + if (dir !== ".") { + const fullPath = path.join(cwd, command) + return (await pathExistsAsFile(fullPath)) ? fullPath : undefined + } + + const envPath = getCaseInsensitive(env, "PATH") + if (paths === undefined && typeof envPath === "string") { + paths = envPath.split(path.delimiter) + } + + if (paths === undefined || paths.length === 0) { + const fullPath = path.join(cwd, command) + return (await pathExistsAsFile(fullPath)) ? fullPath : undefined + } + + for (const pathEntry of paths) { + let fullPath: string + if (path.isAbsolute(pathEntry)) { + fullPath = path.join(pathEntry, command) + } else { + fullPath = path.join(cwd, pathEntry, command) + } + + if (process.platform === "win32") { + const pathExt = getCaseInsensitive(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD" + for (const ext of pathExt.split(";")) { + const withExtension = fullPath + ext + if (await pathExistsAsFile(withExtension)) { + return withExtension + } + } + } + + if (await pathExistsAsFile(fullPath)) { + return fullPath + } + } + + const fullPath = path.join(cwd, command) + return (await pathExistsAsFile(fullPath)) ? fullPath : undefined +} + /** * Find the kilocode CLI executable. * @@ -12,7 +103,7 @@ import { getLocalCliPath } from "./CliInstaller" * 2. Workspace-local build at /cli/dist/index.js * 3. Local installation at ~/.kilocode/cli/pkg (for immutable systems like NixOS) * 4. Login shell lookup (respects user's nvm, fnm, volta, asdf config) - * 5. Direct PATH lookup (fallback for system-wide installs) + * 5. Direct PATH lookup using findExecutable (handles PATHEXT on Windows) * 6. Common npm installation paths (last resort) * * IMPORTANT: Login shell is checked BEFORE direct PATH because: @@ -50,7 +141,6 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise void): Promise void): Promise void): string | null { - const cmd = process.platform === "win32" ? "where kilocode" : "which kilocode" - try { - const result = execSync(cmd, { encoding: "utf-8", timeout: 5000 }).split(/\r?\n/)[0]?.trim() - if (result) { - log?.(`Found CLI in PATH: ${result}`) - return result - } - } catch { - log?.("kilocode not found in direct PATH lookup") - } - return null -} - /** * Try to find kilocode by running `which` in a login shell. * This sources the user's shell profile (~/.zshrc, ~/.bashrc, etc.) * which sets up version managers like nvm, fnm, volta, asdf, etc. - * - * This is the most reliable way to find CLI installed via version managers - * because VS Code's extension host doesn't inherit the user's shell environment. */ function findViaLoginShell(log?: (msg: string) => void): string | null { if (process.platform === "win32") { - // Windows doesn't have the same shell environment concept return null } - // Detect user's shell from SHELL env var, default to bash const userShell = process.env.SHELL || "/bin/bash" const shellName = path.basename(userShell) - // Use login shell (-l) to source profile files, interactive (-i) for some shells - // that only source certain files in interactive mode const shellFlags = shellName === "zsh" ? "-l -i" : "-l" const cmd = `${userShell} ${shellFlags} -c 'which kilocode' 2>/dev/null` @@ -129,8 +196,8 @@ function findViaLoginShell(log?: (msg: string) => void): string | null { log?.(`Trying login shell lookup: ${cmd}`) const result = execSync(cmd, { encoding: "utf-8", - timeout: 10000, // 10s timeout - login shells can be slow - env: { ...process.env, HOME: process.env.HOME }, // Ensure HOME is set + timeout: 10000, + env: { ...process.env, HOME: process.env.HOME }, }) .split(/\r?\n/)[0] ?.trim() @@ -140,7 +207,6 @@ function findViaLoginShell(log?: (msg: string) => void): string | null { return result } } catch (error) { - // This is expected if CLI is not installed or shell init is slow/broken log?.(`Login shell lookup failed (this is normal if CLI not installed via version manager): ${error}`) } @@ -149,7 +215,6 @@ function findViaLoginShell(log?: (msg: string) => void): string | null { /** * Get fallback paths to check for CLI installation. - * This is used when login shell lookup fails or on Windows. */ function getNpmPaths(log?: (msg: string) => void): string[] { const home = process.env.HOME || process.env.USERPROFILE || "" @@ -164,27 +229,16 @@ function getNpmPaths(log?: (msg: string) => void): string[] { ].filter(Boolean) } - // macOS and Linux paths const paths = [ - // Local installation (for immutable systems like NixOS) getLocalCliPath(), - // macOS Homebrew (Apple Silicon) "/opt/homebrew/bin/kilocode", - // macOS Homebrew (Intel) and Linux standard "/usr/local/bin/kilocode", - // Common user-local npm prefix path.join(home, ".npm-global", "bin", "kilocode"), - // nvm: scan installed versions ...getNvmPaths(home, log), - // fnm path.join(home, ".local", "share", "fnm", "aliases", "default", "bin", "kilocode"), - // volta path.join(home, ".volta", "bin", "kilocode"), - // asdf nodejs plugin path.join(home, ".asdf", "shims", "kilocode"), - // Linux snap "/snap/bin/kilocode", - // Linux user local bin path.join(home, ".local", "bin", "kilocode"), ] @@ -193,10 +247,6 @@ function getNpmPaths(log?: (msg: string) => void): string[] { /** * Get potential nvm paths for the kilocode CLI. - * nvm installs node versions in ~/.nvm/versions/node/ - * - * Note: This is a fallback - the login shell approach (findViaLoginShell) - * is preferred because it respects the user's shell configuration. */ function getNvmPaths(home: string, log?: (msg: string) => void): string[] { const nvmDir = process.env.NVM_DIR || path.join(home, ".nvm") @@ -204,16 +254,13 @@ function getNvmPaths(home: string, log?: (msg: string) => void): string[] { const paths: string[] = [] - // Check NVM_BIN if set (current nvm version in the shell) if (process.env.NVM_BIN) { paths.push(path.join(process.env.NVM_BIN, "kilocode")) } - // Scan the nvm versions directory for installed node versions try { if (fs.existsSync(versionsDir)) { const versions = fs.readdirSync(versionsDir) - // Sort versions in reverse order to check newer versions first versions.sort().reverse() log?.(`Found ${versions.length} nvm node versions to check`) for (const version of versions) { @@ -221,7 +268,6 @@ function getNvmPaths(home: string, log?: (msg: string) => void): string[] { } } } catch (error) { - // This is normal if user doesn't have nvm installed log?.(`Could not scan nvm versions directory: ${error}`) } diff --git a/src/core/kilocode/agent-manager/CliProcessHandler.ts b/src/core/kilocode/agent-manager/CliProcessHandler.ts index 003274f1c5..dbf2afdc5a 100644 --- a/src/core/kilocode/agent-manager/CliProcessHandler.ts +++ b/src/core/kilocode/agent-manager/CliProcessHandler.ts @@ -1,4 +1,5 @@ import { spawn, ChildProcess } from "node:child_process" +import * as path from "node:path" import { CliOutputParser, type StreamEvent, @@ -37,6 +38,7 @@ interface PendingProcessInfo { gitUrl?: string stderrBuffer: string[] // Capture stderr for error detection timeoutId?: NodeJS.Timeout // Timer for auto-failing stuck pending sessions + cliPath?: string // CLI path for error telemetry provisionalSessionId?: string // Temporary session ID created when api_req_started arrives (before session_created) } @@ -211,6 +213,7 @@ export class CliProcessHandler { gitUrl: options?.gitUrl, stderrBuffer: [], timeoutId: setTimeout(() => this.handlePendingTimeout(), PENDING_SESSION_TIMEOUT_MS), + cliPath, } } @@ -666,10 +669,24 @@ export class CliProcessHandler { private handleProcessError(proc: ChildProcess, error: Error): void { if (this.pendingProcess && this.pendingProcess.process === proc) { + const cliPath = this.pendingProcess.cliPath this.clearPendingTimeout() this.registry.clearPendingSession() this.callbacks.onPendingSessionChanged(null) this.pendingProcess = null + + // Capture spawn error telemetry with context for debugging + const { platform, shell } = getPlatformDiagnostics() + const cliPathExtension = cliPath ? path.extname(cliPath).slice(1).toLowerCase() || undefined : undefined + captureAgentManagerLoginIssue({ + issueType: "cli_spawn_error", + platform, + shell, + errorMessage: error.message, + cliPath, + cliPathExtension, + }) + this.callbacks.onStartSessionFailed({ type: "spawn_error", message: error.message, diff --git a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts index ea7778f290..bae9b2dfcf 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -6,6 +6,7 @@ const MOCK_CLI_PATH = "/mock/path/to/kilocode" // Mock the local telemetry module vi.mock("../telemetry", () => ({ + getPlatformDiagnostics: vi.fn(() => ({ platform: "darwin", shell: "bash" })), captureAgentManagerOpened: vi.fn(), captureAgentManagerSessionStarted: vi.fn(), captureAgentManagerSessionCompleted: vi.fn(), @@ -45,8 +46,15 @@ describe("AgentManagerProvider CLI spawning", () => { ExtensionMode: { Development: 1, Production: 2, Test: 3 }, })) + // Mock CliInstaller so getLocalCliPath returns our mock path + vi.doMock("../CliInstaller", () => ({ + getLocalCliPath: () => MOCK_CLI_PATH, + })) + + // Mock fileExistsAtPath to return true only for MOCK_CLI_PATH + // This ensures findKilocodeCli finds the CLI via local path check (works on all platforms) vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), + fileExistsAtPath: vi.fn().mockImplementation((p: string) => Promise.resolve(p === MOCK_CLI_PATH)), })) // Mock getRemoteUrl for gitUrl support @@ -89,70 +97,6 @@ describe("AgentManagerProvider CLI spawning", () => { expect(options?.shell).not.toBe(true) }) - it("spawns with shell: true on Windows when CLI path ends with .cmd", async () => { - // Reset modules to set up Windows-specific mock - vi.resetModules() - - const mockWorkspaceFolder = { uri: { fsPath: "/tmp/workspace" } } - const mockProvider = { - getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "kilocode" } }), - } - - vi.doMock("vscode", () => ({ - workspace: { workspaceFolders: [mockWorkspaceFolder] }, - window: { showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), ViewColumn: { One: 1 } }, - env: { openExternal: vi.fn() }, - Uri: { parse: vi.fn(), joinPath: vi.fn() }, - ViewColumn: { One: 1 }, - ExtensionMode: { Development: 1, Production: 2, Test: 3 }, - })) - - vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), - })) - - vi.doMock("../../../../services/code-index/managed/git-utils", () => ({ - getRemoteUrl: vi.fn().mockResolvedValue(undefined), - })) - - class TestProc extends EventEmitter { - stdout = new EventEmitter() - stderr = new EventEmitter() - kill = vi.fn() - pid = 1234 - } - - const spawnMock = vi.fn(() => new TestProc()) - // Return a .cmd path to simulate Windows local CLI installation - const execSyncMock = vi.fn(() => "C:\\Users\\test\\.kilocode\\cli\\pkg\\node_modules\\.bin\\kilocode.cmd") - - vi.doMock("node:child_process", () => ({ - spawn: spawnMock, - execSync: execSyncMock, - })) - - // Mock process.platform to be win32 - const originalPlatform = process.platform - Object.defineProperty(process, "platform", { value: "win32", writable: true }) - - try { - const module = await import("../AgentManagerProvider") - const windowsProvider = new module.AgentManagerProvider(mockContext, mockOutputChannel, mockProvider as any) - - await (windowsProvider as any).startAgentSession("test windows cmd") - - expect(spawnMock).toHaveBeenCalledTimes(1) - const [cmd, , options] = spawnMock.mock.calls[0] as unknown as [string, string[], Record] - expect(cmd.toLowerCase()).toContain(".cmd") - expect(options?.shell).toBe(true) - - windowsProvider.dispose() - } finally { - // Restore original platform - Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }) - } - }) - it("creates pending session and waits for session_created event", async () => { await (provider as any).startAgentSession("test pending") @@ -557,8 +501,13 @@ describe("AgentManagerProvider gitUrl filtering", () => { ExtensionMode: { Development: 1, Production: 2, Test: 3 }, })) + // Mock CliInstaller so getLocalCliPath returns our mock path + vi.doMock("../CliInstaller", () => ({ + getLocalCliPath: () => MOCK_CLI_PATH, + })) + vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), + fileExistsAtPath: vi.fn().mockImplementation((p: string) => Promise.resolve(p === MOCK_CLI_PATH)), })) mockGetRemoteUrl = vi.fn().mockResolvedValue("https://github.com/org/repo.git") @@ -794,8 +743,13 @@ describe("AgentManagerProvider telemetry", () => { ExtensionMode: { Development: 1, Production: 2, Test: 3 }, })) + // Mock CliInstaller so getLocalCliPath returns our mock path + vi.doMock("../CliInstaller", () => ({ + getLocalCliPath: () => MOCK_CLI_PATH, + })) + vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), + fileExistsAtPath: vi.fn().mockImplementation((p: string) => Promise.resolve(p === MOCK_CLI_PATH)), })) vi.doMock("../../../../services/code-index/managed/git-utils", () => ({ diff --git a/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts index 663a65cb5c..7d420fdf06 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest" +import * as path from "node:path" const isWindows = process.platform === "win32" @@ -10,40 +11,45 @@ describe("findKilocodeCli", () => { const loginShellTests = isWindows ? it.skip : it loginShellTests("finds CLI via login shell and returns trimmed result", async () => { - // Login shell is tried first, so mock it to succeed const execSyncMock = vi.fn().mockReturnValue("/Users/test/.nvm/versions/node/v20/bin/kilocode\n") vi.doMock("node:child_process", () => ({ execSync: execSyncMock })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const result = await findKilocodeCli() expect(result).toBe("/Users/test/.nvm/versions/node/v20/bin/kilocode") - // First call should be login shell (on non-Windows) expect(execSyncMock).toHaveBeenCalledWith( expect.stringContaining("which kilocode"), expect.objectContaining({ encoding: "utf-8" }), ) }) - loginShellTests("falls back to direct PATH when login shell fails", async () => { - let callCount = 0 - const execSyncMock = vi.fn().mockImplementation((cmd: string) => { - callCount++ - // First call (login shell) fails, second call (direct PATH) succeeds - if (callCount === 1) { - throw new Error("login shell failed") + loginShellTests("falls back to findExecutable when login shell fails", async () => { + const execSyncMock = vi.fn().mockImplementation(() => { + throw new Error("login shell failed") + }) + const statMock = vi.fn().mockImplementation((filePath: string) => { + if (filePath === "/usr/local/bin/kilocode") { + return Promise.resolve({ isFile: () => true }) } - return "/usr/local/bin/kilocode\n" + return Promise.reject(new Error("ENOENT")) }) vi.doMock("node:child_process", () => ({ execSync: execSyncMock })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: statMock }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const result = await findKilocodeCli() expect(result).toBe("/usr/local/bin/kilocode") - expect(execSyncMock).toHaveBeenCalledTimes(2) }) it("falls back to npm paths when all PATH lookups fail", async () => { @@ -51,11 +57,14 @@ describe("findKilocodeCli", () => { throw new Error("not found") }) const fileExistsMock = vi.fn().mockImplementation((path: string) => { - // Return true for first path checked to verify fallback works return Promise.resolve(path.includes("kilocode")) }) vi.doMock("node:child_process", () => ({ execSync: execSyncMock })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: fileExistsMock })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const result = await findKilocodeCli() @@ -71,6 +80,10 @@ describe("findKilocodeCli", () => { }), })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const logMock = vi.fn() @@ -80,18 +93,176 @@ describe("findKilocodeCli", () => { expect(logMock).toHaveBeenCalledWith("kilocode CLI not found") }) - it("logs when kilocode not in direct PATH", async () => { + it("logs when kilocode not in PATH", async () => { vi.doMock("node:child_process", () => ({ execSync: vi.fn().mockImplementation(() => { throw new Error("not found") }), })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const logMock = vi.fn() await findKilocodeCli(logMock) - expect(logMock).toHaveBeenCalledWith("kilocode not found in direct PATH lookup") + expect(logMock).toHaveBeenCalledWith("kilocode not found in PATH lookup") + }) +}) + +describe("findExecutable", () => { + beforeEach(() => { + vi.resetModules() + }) + + // These tests use Unix-style paths which are not absolute on Windows + // Skip on Windows - the Windows-specific behavior is tested below + const unixOnlyTest = isWindows ? it.skip : it + + unixOnlyTest("returns absolute path if file exists", async () => { + const statMock = vi.fn().mockResolvedValue({ isFile: () => true }) + vi.doMock("node:fs", () => ({ + promises: { stat: statMock }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("/usr/bin/kilocode") + + expect(result).toBe("/usr/bin/kilocode") + }) + + unixOnlyTest("returns undefined for absolute path if file does not exist", async () => { + const statMock = vi.fn().mockRejectedValue(new Error("ENOENT")) + vi.doMock("node:fs", () => ({ + promises: { stat: statMock }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("/usr/bin/nonexistent") + + expect(result).toBeUndefined() + }) + + unixOnlyTest("searches PATH entries for command", async () => { + const statMock = vi.fn().mockImplementation((filePath: string) => { + if (filePath === "/custom/bin/myapp") { + return Promise.resolve({ isFile: () => true }) + } + return Promise.reject(new Error("ENOENT")) + }) + vi.doMock("node:fs", () => ({ + promises: { stat: statMock }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("myapp", "/home/user", ["/usr/bin", "/custom/bin"]) + + expect(result).toBe("/custom/bin/myapp") + }) + + // Windows PATHEXT tests - run only on Windows CI + // We don't simulate Windows on other platforms - let actual Windows CI test it + describe("Windows PATHEXT handling", () => { + const windowsOnlyTest = isWindows ? it : it.skip + const testDir = "C:\\npm" + const testCwd = "C:\\home\\test" + + const createFsMock = (matchPaths: string[]) => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { + stat: vi.fn().mockImplementation((filePath: string) => { + if (matchPaths.some((p) => filePath === p)) { + return Promise.resolve({ isFile: () => true }) + } + return Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }), + lstat: vi.fn().mockImplementation((filePath: string) => { + if (matchPaths.some((p) => filePath === p)) { + return Promise.resolve({ isFile: () => true, isSymbolicLink: () => false }) + } + return Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }), + }, + }) + + windowsOnlyTest("finds .CMD file via PATHEXT", async () => { + const expectedPath = path.join(testDir, "kilocode") + ".CMD" + vi.doMock("node:fs", () => createFsMock([expectedPath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, [testDir], { + PATH: testDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }) + + expect(result).toBe(expectedPath) + }) + + windowsOnlyTest("uses default PATHEXT when not in env", async () => { + const expectedPath = path.join(testDir, "kilocode") + ".CMD" + vi.doMock("node:fs", () => createFsMock([expectedPath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, [testDir], { + PATH: testDir, + }) + + expect(result).toBe(expectedPath) + }) + + windowsOnlyTest("handles case-insensitive env var lookup", async () => { + const expectedPath = path.join(testDir, "kilocode") + ".EXE" + vi.doMock("node:fs", () => createFsMock([expectedPath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, undefined, { + Path: testDir, // lowercase 'ath' - Windows env vars are case-insensitive + PathExt: ".COM;.EXE;.BAT;.CMD", + }) + + expect(result).toBe(expectedPath) + }) + + windowsOnlyTest("returns first matching PATHEXT extension", async () => { + const comPath = path.join(testDir, "kilocode") + ".COM" + const exePath = path.join(testDir, "kilocode") + ".EXE" + vi.doMock("node:fs", () => createFsMock([comPath, exePath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, [testDir], { + PATH: testDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }) + + expect(result).toBe(comPath) + }) + }) + + // Non-Windows test - skipped on Windows since we can't simulate other platforms + const nonWindowsTest = isWindows ? it.skip : it + + nonWindowsTest("does not use PATHEXT on non-Windows platforms", async () => { + const statMock = vi.fn().mockImplementation((filePath: string) => { + if (filePath === "/usr/bin/kilocode") { + return Promise.resolve({ isFile: () => true }) + } + return Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { + stat: statMock, + lstat: vi.fn().mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", "/home/user", ["/usr/bin"]) + + expect(result).toBe("/usr/bin/kilocode") + expect(statMock).not.toHaveBeenCalledWith(expect.stringContaining(".CMD")) }) }) diff --git a/src/core/kilocode/agent-manager/telemetry.ts b/src/core/kilocode/agent-manager/telemetry.ts index 3d53774ab1..322ce237cf 100644 --- a/src/core/kilocode/agent-manager/telemetry.ts +++ b/src/core/kilocode/agent-manager/telemetry.ts @@ -29,6 +29,10 @@ export interface AgentManagerLoginIssueProperties { httpStatusCode?: number platform?: "darwin" | "win32" | "linux" | "other" shell?: string + // Spawn error details for debugging Windows issues + errorMessage?: string + cliPath?: string + cliPathExtension?: string } export function captureAgentManagerOpened(): void {