diff --git a/.changeset/curly-jokes-cut.md b/.changeset/curly-jokes-cut.md new file mode 100644 index 00000000000..2bcd1dff542 --- /dev/null +++ b/.changeset/curly-jokes-cut.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +JetBrains IDEs: Autocomplete is now available and can be enabled in Settings > Autocomplete. diff --git a/.changeset/gentle-plants-smile.md b/.changeset/gentle-plants-smile.md new file mode 100644 index 00000000000..5e7c4371e01 --- /dev/null +++ b/.changeset/gentle-plants-smile.md @@ -0,0 +1,11 @@ +--- +"kilo-code": minor +--- + +Added a new device authorization flow for Kilo Gateway that makes it easier to connect your editor to your Kilo account. Instead of manually copying API tokens, you can now: + +- Scan a QR code with your phone or click to open the authorization page in your browser +- Approve the connection from your browser +- Automatically get authenticated without copying any tokens + +This streamlined workflow provides a more secure and user-friendly way to authenticate, similar to how you connect devices to services like Netflix or YouTube. diff --git a/.changeset/thin-dots-smoke.md b/.changeset/thin-dots-smoke.md new file mode 100644 index 00000000000..59b261bf98a --- /dev/null +++ b/.changeset/thin-dots-smoke.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Jetbrains - Fix open external urls diff --git a/.changeset/tidy-donuts-judge.md b/.changeset/tidy-donuts-judge.md new file mode 100644 index 00000000000..632196f707f --- /dev/null +++ b/.changeset/tidy-donuts-judge.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Autocomplete support to the chat text box. It can be enabled/disabled using a new toggle in the autocomplete settings menu diff --git a/apps/kilocode-docs/docs/advanced-usage/cloud-agent.md b/apps/kilocode-docs/docs/advanced-usage/cloud-agent.md index b23c7d3f55d..58cb7d6970b 100644 --- a/apps/kilocode-docs/docs/advanced-usage/cloud-agent.md +++ b/apps/kilocode-docs/docs/advanced-usage/cloud-agent.md @@ -49,7 +49,7 @@ Your work is always pushed to GitHub, ensuring nothing is lost. ## How Cloud Agents Work -- Each user receives an **isolated Linux container** with common dev tools preinstalled (Python, Node.js, git, etc.). +- Each user receives an **isolated Linux container** with common dev tools preinstalled (Node.js, git, gh CLI, etc.). - All Cloud Agent chats share a **single container instance**, while each session gets its own workspace directory. - When a session begins: @@ -67,7 +67,7 @@ Your work is always pushed to GitHub, ensuring nothing is lost. - Containers are **ephemeral**: - Spindown occurs after inactivity - Expect slightly longer setup after idle periods - - Inactive sessions are deleted after **7 days** during the beta + - Inactive cloud agent sessions are deleted after **7 days** during the beta, expired sessions are still accessible via the CLI --- @@ -105,11 +105,12 @@ Cloud Agents are great for: ## Limitations and Guidance -- Each message can run for **up to 10 minutes**. +- Each message can run for **up to 15 minutes**. Break large tasks into smaller steps; use a `plan.md` or `todo.md` file to keep scope clear. -- **Context is not persistent across messages yet.** - Kilo Code does not remember previous turns; persistent in-repo notes help keep it aligned. -- **Auto/YOLO mode is always on.** +- **Context is persistent across messages.** + Kilo Code remembers previous turns within the same session. +- **Auto/YOLO mode is always on.** The agent will modify code without prompting for confirmation. -- **Saved sessions** in the sidebar are not yet shared between logins or restorable locally. +- **Sessions are restorable locally** and local sessions can be resumed in Cloud Agent. +- **Sessions prior to December 9th 2025** may not be accessible in the web UI. - **MCP support is coming**, but **Docker-based MCP servers will _not_ be supported**. diff --git a/apps/kilocode-docs/docs/cli.md b/apps/kilocode-docs/docs/cli.md index 8f47db021ae..ebfee0b0fb9 100644 --- a/apps/kilocode-docs/docs/cli.md +++ b/apps/kilocode-docs/docs/cli.md @@ -26,6 +26,12 @@ kilocode --continue to start the CLI and begin a new task with your preferred model and relevant mode. +## Update + +Upgrade the Kilo CLI package: + +`npm update -g @kilocode/cli` + ## What you can do with Kilo Code CLI - **Plan and execute code changes without leaving your terminal.** Use your command line to make edits to your project without opening your IDE. diff --git a/jetbrains/host/src/rpcManager.ts b/jetbrains/host/src/rpcManager.ts index a9f6d281138..26ca8c36de9 100644 --- a/jetbrains/host/src/rpcManager.ts +++ b/jetbrains/host/src/rpcManager.ts @@ -22,6 +22,7 @@ import { IRemoteConsoleLog } from "../deps/vscode/vs/base/common/console.js" import { FileType, FilePermission, FileSystemProviderErrorCode } from "../deps/vscode/vs/platform/files/common/files.js" import * as fs from "fs" import { promisify } from "util" +import { exec } from "child_process" import { ConfigurationModel } from "../deps/vscode/vs/platform/configuration/common/configurationModels.js" import { NullLogService } from "../deps/vscode/vs/platform/log/common/log.js" import { ExtensionIdentifier } from "../deps/vscode/vs/platform/extensions/common/extensions.js" @@ -307,9 +308,61 @@ export class RPCManager { console.log("Get initial state") return Promise.resolve({ isFocused: false, isActive: false }) }, - $openUri(uri: UriComponents, uriString: string | undefined, options: any): Promise { + async $openUri(uri: UriComponents, uriString: string | undefined, options: any): Promise { console.log("Open URI:", { uri, uriString, options }) - return Promise.resolve(true) + + try { + // Use the uriString if provided, otherwise construct from uri components + const urlToOpen = uriString || this.constructUriString(uri) + + if (!urlToOpen) { + console.error("No valid URL to open") + return false + } + + console.log("Opening URL in browser:", urlToOpen) + + // Open URL in default browser based on platform + const execAsync = promisify(exec) + let command: string + + switch (process.platform) { + case "darwin": // macOS + command = `open "${urlToOpen}"` + break + case "win32": // Windows + command = `start "" "${urlToOpen}"` + break + default: // Linux and others + command = `xdg-open "${urlToOpen}"` + break + } + + await execAsync(command) + console.log("Successfully opened URL in browser") + return true + } catch (error) { + console.error("Failed to open URI:", error) + return false + } + }, + constructUriString(uri: UriComponents): string | null { + if (!uri) return null + + const scheme = uri.scheme || "https" + const authority = uri.authority || "" + const path = uri.path || "" + const query = uri.query ? `?${uri.query}` : "" + const fragment = uri.fragment ? `#${uri.fragment}` : "" + + // Construct the full URI + if (authority) { + return `${scheme}://${authority}${path}${query}${fragment}` + } else if (path) { + return `${scheme}:${path}${query}${fragment}` + } + + return null }, $asExternalUri(uri: UriComponents, options: any): Promise { console.log("As external URI:", { uri, options }) diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadCommandsShape.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadCommandsShape.kt index b70e70c251a..98c837b9a35 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadCommandsShape.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadCommandsShape.kt @@ -6,6 +6,7 @@ package ai.kilocode.jetbrains.actors import ai.kilocode.jetbrains.commands.CommandRegistry import ai.kilocode.jetbrains.commands.ICommand +import ai.kilocode.jetbrains.commands.registerSetContextCommands import ai.kilocode.jetbrains.editor.registerOpenEditorAPICommands import ai.kilocode.jetbrains.terminal.registerTerminalAPICommands import ai.kilocode.jetbrains.util.doInvokeMethod @@ -68,6 +69,7 @@ class MainThreadCommands(val project: Project) : MainThreadCommandsShape { init { registerOpenEditorAPICommands(project, registry) registerTerminalAPICommands(project, registry) + registerSetContextCommands(project, registry) // TODO other commands } 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 dc8676f000f..f4a8ebba311 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 @@ -1,12 +1,10 @@ -// SPDX-FileCopyrightText: 2025 Weibo, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - package ai.kilocode.jetbrains.actors import ai.kilocode.jetbrains.core.ExtensionIdentifier +import ai.kilocode.jetbrains.inline.InlineCompletionManager import com.intellij.openapi.Disposable import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project /** * Language features related interface. @@ -448,11 +446,26 @@ interface MainThreadLanguageFeaturesShape : Disposable { * concrete implementations for all language feature registration methods. * It acts as a bridge between the extension host and the IDE's language services. */ -class MainThreadLanguageFeatures : MainThreadLanguageFeaturesShape { +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. + */ + private val inlineCompletionManager: InlineCompletionManager by lazy { + InlineCompletionManager(project) + } override fun unregister(handle: Int) { logger.info("Unregistering service: handle=$handle") + + // Try to unregister from inline completion manager + try { + inlineCompletionManager.unregisterProvider(handle) + } catch (e: Exception) { + logger.warn("Failed to unregister inline completion provider: handle=$handle", e) + } } override fun registerDocumentSymbolProvider(handle: Int, selector: List>, label: String) { @@ -614,7 +627,22 @@ class MainThreadLanguageFeatures : MainThreadLanguageFeaturesShape { displayName: String?, debounceDelayMs: Int?, ) { - logger.info("Registering inline completions support: handle=$handle, selector=$selector, supportsHandleDidShowCompletionItem=$supportsHandleDidShowCompletionItem, extensionId=$extensionId, yieldsToExtensionIds=$yieldsToExtensionIds, displayName=$displayName, debounceDelayMs=$debounceDelayMs") + logger.info("Registering inline completions support: handle=$handle, extensionId=$extensionId, displayName=$displayName") + + try { + inlineCompletionManager.registerProvider( + handle = handle, + selector = selector, + supportsHandleDidShowCompletionItem = supportsHandleDidShowCompletionItem, + extensionId = extensionId, + yieldsToExtensionIds = yieldsToExtensionIds, + displayName = displayName, + debounceDelayMs = debounceDelayMs + ) + logger.info("Successfully registered inline completion provider: handle=$handle") + } catch (e: Exception) { + logger.error("Failed to register inline completion provider: handle=$handle", e) + } } override fun registerInlineEditProvider( @@ -709,5 +737,12 @@ class MainThreadLanguageFeatures : MainThreadLanguageFeaturesShape { override fun dispose() { logger.info("Disposing MainThreadLanguageFeatures resources") + + // Dispose inline completion manager + try { + inlineCompletionManager.dispose() + } catch (e: Exception) { + logger.error("Error disposing inline completion manager", e) + } } } diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadWindowShape.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadWindowShape.kt index 19a5414dd3e..d55407364d9 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadWindowShape.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadWindowShape.kt @@ -4,11 +4,11 @@ package ai.kilocode.jetbrains.actors +import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.wm.WindowManager -import java.awt.Desktop import java.net.URI /** @@ -73,29 +73,22 @@ class MainThreadWindow(val project: Project) : MainThreadWindowShape { try { logger.info("Opening URI: $uriString") - // Try to get URI - val actualUri = if (uriString != null) { - try { - URI(uriString) - } catch (e: Exception) { - // If URI string is invalid, try to build from URI components - createUriFromComponents(uri) - } + // Try to get URI string + val urlToOpen = if (uriString != null) { + uriString } else { - createUriFromComponents(uri) + // Build from URI components + val actualUri = createUriFromComponents(uri) + actualUri?.toString() } - return if (actualUri != null) { - // Check if Desktop operation is supported - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { - Desktop.getDesktop().browse(actualUri) - true - } else { - logger.warn("System does not support opening URI") - false - } + return if (urlToOpen != null) { + // Use IntelliJ's BrowserUtil which works reliably in JetBrains IDEs + BrowserUtil.browse(urlToOpen) + logger.info("Successfully opened URI in browser: $urlToOpen") + true } else { - logger.warn("Cannot create valid URI") + logger.warn("Cannot create valid URI from components: $uri") false } } catch (e: Exception) { 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 new file mode 100644 index 00000000000..d1371797871 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt @@ -0,0 +1,80 @@ +package ai.kilocode.jetbrains.commands + +import ai.kilocode.jetbrains.core.ContextManager +import com.intellij.openapi.diagnostic.Logger +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 + */ +fun registerSetContextCommands(project: Project, registry: CommandRegistry) { + // Register the primary command + registry.registerCommand( + object : ICommand { + 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 + */ +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) + */ + suspend fun setContext(key: String, value: Any?): Any? { + try { + logger.info("Setting context: $key = $value") + contextManager.setContext(key, value) + logger.debug("Context successfully set: $key") + } catch (e: Exception) { + 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 new file mode 100644 index 00000000000..d6a3d94a106 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt @@ -0,0 +1,122 @@ +package ai.kilocode.jetbrains.core + +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +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) + * contextManager.setContext("kilocode.ghost.enableQuickInlineTaskKeybinding", true) + * val value = contextManager.getContext("kilocode.ghost.enableQuickInlineTaskKeybinding") + * ``` + */ +@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) + */ + fun setContext(key: String, value: Any?) { + if (value == null) { + removeContext(key) + return + } + + val previousValue = contexts.put(key, value) + + if (logger.isDebugEnabled) { + if (previousValue != null) { + logger.debug("Context updated: $key = $value (previous: $previousValue)") + } else { + logger.debug("Context set: $key = $value") + } + } + } + + /** + * 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) { + val previousValue = contexts.remove(key) + if (previousValue != null && logger.isDebugEnabled) { + 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. + */ + fun clearAll() { + val count = contexts.size + contexts.clear() + if (logger.isDebugEnabled) { + 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 + */ + fun getInstance(project: Project): ContextManager { + return project.getService(ContextManager::class.java) + } + } +} \ No newline at end of file diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/RPCManager.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/RPCManager.kt index 43633144c7e..00b22c59b21 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/RPCManager.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/RPCManager.kt @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2025 Weibo, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - package ai.kilocode.jetbrains.core import ai.kilocode.jetbrains.actors.MainThreadBulkEdits @@ -255,7 +251,7 @@ class RPCManager( rpcProtocol.set(ServiceProxyRegistry.MainContext.MainThreadUrls, MainThreadUrls()) // MainThreadLanguageFeatures - rpcProtocol.set(ServiceProxyRegistry.MainContext.MainThreadLanguageFeatures, MainThreadLanguageFeatures()) + rpcProtocol.set(ServiceProxyRegistry.MainContext.MainThreadLanguageFeatures, MainThreadLanguageFeatures(project)) // MainThreadFileSystem rpcProtocol.set(ServiceProxyRegistry.MainContext.MainThreadFileSystem, MainThreadFileSystem()) diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ServiceProxyRegistry.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ServiceProxyRegistry.kt index 0de5494de91..6a6846c6779 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ServiceProxyRegistry.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ServiceProxyRegistry.kt @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2025 Weibo, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - package ai.kilocode.jetbrains.core import ai.kilocode.jetbrains.actors.MainThreadBulkEditsShape 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 new file mode 100644 index 00000000000..ff4811d2bb3 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt @@ -0,0 +1,17 @@ +package ai.kilocode.jetbrains.inline + +/** + * Shared constants for inline completion functionality. + */ +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 new file mode 100644 index 00000000000..15bc10e1eb2 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt @@ -0,0 +1,233 @@ +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 + * @param extensionId The ID of the extension providing completions + * @param yieldsToExtensionIds List of extension IDs this provider yields to + * @param displayName Optional display name for the provider + * @param debounceDelayMs Optional debounce delay (handled by extension, not used here) + */ + fun registerProvider( + handle: Int, + selector: List>, + supportsHandleDidShowCompletionItem: Boolean, + extensionId: String, + yieldsToExtensionIds: List, + displayName: String?, + 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 + ) + + // 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 + ) + + 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 { + // Unregister from extension point + 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) + } + } else { + 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 + * @return true if the document matches the selector + */ + 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: + * - language: Language ID pattern + * - scheme: URI scheme pattern + * - pattern: File path pattern (glob) + */ + private fun matchesSelectorItem( + selectorItem: Map, + languageId: String?, + fileName: String? + ): Boolean { + // Check language pattern + val language = selectorItem["language"] as? String + if (language != null && language != "*") { + if (languageId == null || !matchesPattern(languageId, language)) { + return false + } + } + + // Check file pattern + val pattern = selectorItem["pattern"] as? String + if (pattern != null && pattern != "**/*") { + if (fileName == null || !matchesGlobPattern(fileName, pattern)) { + return false + } + } + + // Check scheme (usually "file" for local files) + val scheme = selectorItem["scheme"] as? String + if (scheme != null && scheme != "*") { + // For now, we only support "file" scheme + if (scheme != "file") { + 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(".", "\\.") + .replace("**", ".*") + .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. + */ + private data class ProviderRegistration( + val provider: KiloCodeInlineCompletionProvider, + val selector: List>, + val extensionId: String, + 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 new file mode 100644 index 00000000000..26e2daaf453 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt @@ -0,0 +1,251 @@ +package ai.kilocode.jetbrains.inline + +import ai.kilocode.jetbrains.core.PluginContext +import ai.kilocode.jetbrains.core.ServiceProxyRegistry +import ai.kilocode.jetbrains.i18n.I18n +import ai.kilocode.jetbrains.ipc.proxy.LazyPromise +import ai.kilocode.jetbrains.ipc.proxy.interfaces.ExtHostCommandsProxy +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import kotlinx.coroutines.withTimeout +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference + +/** + * Service responsible for getting inline completions via RPC communication + * with the VSCode extension's Ghost service. Encapsulates all RPC logic, + * error handling, and result processing for inline completion generation. + */ +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. + */ + private val currentRequestId = AtomicReference(null) + + /** + * Result wrapper for inline completion operations. + */ + sealed class Result { + data class Success(val items: List) : Result() + data class Error(val errorMessage: String) : Result() + } + + /** + * Completion item data class representing a single inline completion suggestion. + */ + data class CompletionItem( + val insertText: String, + val range: Range? + ) + + /** + * Range data class representing a text range in the document. + */ + data class Range( + val start: Position, + val end: Position + ) + + /** + * Position data class representing a cursor position in the document. + */ + data class Position( + val line: Int, + val character: Int + ) + + /** + * Gets inline completions using the VSCode extension via RPC. + * Sends the full file content to ensure accurate completions. + * + * @param project The current project context + * @param document The document to get completions for + * @param line The line number (0-based) + * @param character The character position (0-based) + * @param languageId The language identifier (e.g., "kotlin", "java") + * @return Result containing either the completion items or error information + */ + suspend fun getInlineCompletions( + project: Project, + document: Document, + line: Int, + character: Int, + languageId: String + ): Result { + return try { + val proxy = getRPCProxy(project) + if (proxy == null) { + logger.error("Failed to get RPC proxy - extension not connected") + return Result.Error(I18n.t("kilocode:inlineCompletion.errors.connectionFailed")) + } + + // Generate unique request ID and mark it as current + val requestId = UUID.randomUUID().toString() + currentRequestId.set(requestId) + val rpcResult = executeRPCCommand(proxy, document, line, character, languageId, requestId) + processCommandResult(rpcResult, requestId) + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + logger.debug("Inline completion timed out after ${InlineCompletionConstants.RPC_TIMEOUT_MS}ms - returning empty result") + Result.Success(emptyList()) + } catch (e: kotlinx.coroutines.CancellationException) { + // Normal cancellation - user continued typing or request was superseded + // This is expected behavior, not an error + logger.debug("Inline completion cancelled (user continued typing)", e) + Result.Success(emptyList()) // Return empty result, not an error + } catch (e: java.util.concurrent.CancellationException) { + // Java cancellation exception - also normal flow + logger.debug("Inline completion cancelled (Java cancellation)", e) + Result.Success(emptyList()) + } catch (e: Exception) { + // 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) { + logger.debug("Inline completion cancelled (wrapped exception): ${e.message}") + return Result.Success(emptyList()) + } + // Real error - log as warning and return empty result silently + logger.warn("Inline completion failed: ${e.message}", e) + Result.Success(emptyList()) + } + } + + /** + * Gets the RPC proxy for command execution from the project's PluginContext. + */ + private fun getRPCProxy(project: Project): ExtHostCommandsProxy? { + return project.getService(PluginContext::class.java) + ?.getRPCProtocol() + ?.getProxy(ServiceProxyRegistry.ExtHostContext.ExtHostCommands) + } + + /** + * Executes the inline completion command via RPC with timeout handling. + * Sends the full document content to the VSCode extension. + */ + private suspend fun executeRPCCommand( + proxy: ExtHostCommandsProxy, + document: Document, + line: Int, + character: Int, + languageId: 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 + ), + fileContent, + languageId, + requestId + ) + + val promise: LazyPromise = proxy.executeContributedCommand( + InlineCompletionConstants.EXTERNAL_COMMAND_ID, + args, + ) + + // Wait for the result with timeout + val result = withTimeout(InlineCompletionConstants.RPC_TIMEOUT_MS) { + promise.await() + } + + return result + } + + /** + * Processes the result from the RPC command and returns appropriate Result. + * Parses the response map and extracts completion items. + */ + private fun processCommandResult(result: Any?, requestId: String): Result { + // Handle invalid result format + if (result !is Map<*, *>) { + logger.warn("Received unexpected response format: ${result?.javaClass?.simpleName}, result: $result") + return Result.Success(emptyList()) + } + + // Extract response data including request ID + val responseRequestId = result["requestId"] as? String + val items = result["items"] as? List<*> + val error = result["error"] as? String + + // Validate request ID - only process if it matches the current request + val current = currentRequestId.get() + if (responseRequestId != current) { + 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") + return Result.Success(emptyList()) + } + + // Handle missing items + if (items == null) { + logger.warn("Received response without items or error field") + return Result.Success(emptyList()) + } + + // Parse completion items + val completionItems = items.mapNotNull { item -> + if (item is Map<*, *>) { + val insertText = item["insertText"] as? String + if (insertText == null) { + logger.warn(" Item missing insertText, skipping") + return@mapNotNull null + } + val rangeMap = item["range"] as? Map<*, *> + val range = rangeMap?.let { + val start = it["start"] as? Map<*, *> + val end = it["end"] as? Map<*, *> + if (start != null && end != null) { + Range( + Position( + (start["line"] as? Number)?.toInt() ?: 0, + (start["character"] as? Number)?.toInt() ?: 0 + ), + Position( + (end["line"] as? Number)?.toInt() ?: 0, + (end["character"] as? Number)?.toInt() ?: 0 + ) + ) + } else null + } + CompletionItem(insertText, range) + } else { + logger.warn(" Item is not a Map, skipping") + null + } + } + + // Success case + return Result.Success(completionItems) + } + + companion object { + /** + * Gets or creates the InlineCompletionService instance. + */ + fun getInstance(): 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/KiloCodeInlineCompletionProvider.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt new file mode 100644 index 00000000000..5a2deddfae4 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt @@ -0,0 +1,134 @@ +package ai.kilocode.jetbrains.inline + +import com.intellij.codeInsight.inline.completion.InlineCompletionEvent +import com.intellij.codeInsight.inline.completion.InlineCompletionProvider +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 +import com.intellij.openapi.project.Project + +/** + * IntelliJ inline completion provider that bridges to VSCode extension's Ghost service. + * This provider uses the new InlineCompletionService which sends full file content + * to the Ghost service via RPC for accurate completions. + * + * The provider handles triggering and rendering, while all AI logic (debouncing, + * caching, context gathering, and telemetry) is handled by the Ghost service. + */ +class KiloCodeInlineCompletionProvider( + private val handle: Int, + private val project: Project, + private val extensionId: String, + private val displayName: String? +) : InlineCompletionProvider { + + private val logger = Logger.getInstance(KiloCodeInlineCompletionProvider::class.java) + private val completionService = InlineCompletionService.getInstance() + + /** + * 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. + */ + override suspend fun getSuggestion(request: InlineCompletionRequest): InlineCompletionSingleSuggestion { + try { + // Get document and position information within a read action + // We need to get the document reference here too for thread safety + 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 + ) + + // Convert result to InlineCompletionSingleSuggestion using the new API + return when (result) { + is InlineCompletionService.Result.Success -> { + if (result.items.isEmpty()) { + // Return empty suggestion using builder + InlineCompletionSingleSuggestion.build { } + } else { + val firstItem = result.items[0] + InlineCompletionSingleSuggestion.build { + emit(InlineCompletionGrayTextElement(firstItem.insertText)) + } + } + } + is InlineCompletionService.Result.Error -> { + InlineCompletionSingleSuggestion.build { } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + // Normal cancellation - user continued typing + throw e // Re-throw to properly propagate cancellation + } catch (e: java.util.concurrent.CancellationException) { + // Java cancellation - also normal flow + return InlineCompletionSingleSuggestion.build { } + } catch (e: Exception) { + // Check if this is a wrapped cancellation + if (e.cause is kotlinx.coroutines.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. + */ + override fun isEnabled(event: InlineCompletionEvent): Boolean { + return true + } + + /** + * Data class to hold position information calculated in read action + */ + private data class PositionInfo( + val offset: Int, + val lineNumber: Int, + val character: Int, + val languageId: String, + val document: com.intellij.openapi.editor.Document, + val caretOffset: Int + ) +} \ No newline at end of file diff --git a/jetbrains/plugin/src/main/resources/META-INF/plugin.xml.template b/jetbrains/plugin/src/main/resources/META-INF/plugin.xml.template index 9190435e06c..b2d3439b11f 100644 --- a/jetbrains/plugin/src/main/resources/META-INF/plugin.xml.template +++ b/jetbrains/plugin/src/main/resources/META-INF/plugin.xml.template @@ -56,6 +56,7 @@ SPDX-License-Identifier: Apache-2.0 /> + diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9d667ae471f..ca2aac9b6f2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -28,6 +28,7 @@ export * from "./tool-params.js" export * from "./type-fu.js" export * from "./vscode.js" export * from "./kilocode/kilocode.js" +export * from "./kilocode/device-auth.js" // kilocode_change export * from "./kilocode/nativeFunctionCallingProviders.js" export * from "./usage-tracker.js" // kilocode_change diff --git a/packages/types/src/kilocode/device-auth.ts b/packages/types/src/kilocode/device-auth.ts new file mode 100644 index 00000000000..bf47c84be71 --- /dev/null +++ b/packages/types/src/kilocode/device-auth.ts @@ -0,0 +1,51 @@ +import { z } from "zod" + +/** + * Device authorization response from initiate endpoint + */ +export const DeviceAuthInitiateResponseSchema = z.object({ + /** Verification code to display to user */ + code: z.string(), + /** URL for user to visit in browser */ + verificationUrl: z.string(), + /** Time in seconds until code expires */ + expiresIn: z.number(), +}) + +export type DeviceAuthInitiateResponse = z.infer + +/** + * Device authorization poll response + */ +export const DeviceAuthPollResponseSchema = z.object({ + /** Current status of the authorization */ + status: z.enum(["pending", "approved", "denied", "expired"]), + /** API token (only present when approved) */ + token: z.string().optional(), + /** User ID (only present when approved) */ + userId: z.string().optional(), + /** User email (only present when approved) */ + userEmail: z.string().optional(), +}) + +export type DeviceAuthPollResponse = z.infer + +/** + * Device auth state for UI + */ +export interface DeviceAuthState { + /** Current status of the auth flow */ + status: "idle" | "initiating" | "pending" | "polling" | "success" | "error" | "cancelled" + /** Verification code */ + code?: string + /** URL to visit for verification */ + verificationUrl?: string + /** Expiration time in seconds */ + expiresIn?: number + /** Error message if failed */ + error?: string + /** Time remaining in seconds */ + timeRemaining?: number + /** User email when successful */ + userEmail?: string +} diff --git a/packages/types/src/kilocode/kilocode.ts b/packages/types/src/kilocode/kilocode.ts index 72a40fb652d..eade06eb87e 100644 --- a/packages/types/src/kilocode/kilocode.ts +++ b/packages/types/src/kilocode/kilocode.ts @@ -11,6 +11,7 @@ export const ghostServiceSettingsSchema = z enableAutoTrigger: z.boolean().optional(), enableQuickInlineTaskKeybinding: z.boolean().optional(), enableSmartInlineTaskKeybinding: z.boolean().optional(), + enableChatAutocomplete: z.boolean().optional(), provider: z.string().optional(), model: z.string().optional(), }) diff --git a/src/core/kilocode/webview/deviceAuthHandler.ts b/src/core/kilocode/webview/deviceAuthHandler.ts new file mode 100644 index 00000000000..c90019bc70b --- /dev/null +++ b/src/core/kilocode/webview/deviceAuthHandler.ts @@ -0,0 +1,150 @@ +import * as vscode from "vscode" +import { DeviceAuthService } from "../../../services/kilocode/DeviceAuthService" +import type { ExtensionMessage } from "../../../shared/ExtensionMessage" + +/** + * Callbacks required by DeviceAuthHandler to communicate with the provider + */ +export interface DeviceAuthHandlerCallbacks { + postMessageToWebview: (message: ExtensionMessage) => Promise + log: (message: string) => void + showInformationMessage: (message: string) => void +} + +/** + * Handles device authorization flow for Kilo Code authentication + * This class encapsulates all device auth logic to keep ClineProvider clean + */ +export class DeviceAuthHandler { + private deviceAuthService?: DeviceAuthService + private callbacks: DeviceAuthHandlerCallbacks + + constructor(callbacks: DeviceAuthHandlerCallbacks) { + this.callbacks = callbacks + } + + /** + * Start the device authorization flow + */ + async startDeviceAuth(): Promise { + try { + // Clean up any existing device auth service + if (this.deviceAuthService) { + this.deviceAuthService.dispose() + } + + this.deviceAuthService = new DeviceAuthService() + + // Set up event listeners + this.deviceAuthService.on("started", (data: any) => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthStarted", + deviceAuthCode: data.code, + deviceAuthVerificationUrl: data.verificationUrl, + deviceAuthExpiresIn: data.expiresIn, + }) + // Open browser automatically + vscode.env.openExternal(vscode.Uri.parse(data.verificationUrl)) + }) + + this.deviceAuthService.on("polling", (timeRemaining: any) => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthPolling", + deviceAuthTimeRemaining: timeRemaining, + }) + }) + + this.deviceAuthService.on("success", async (token: any, userEmail: any) => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthComplete", + deviceAuthToken: token, + deviceAuthUserEmail: userEmail, + }) + + this.callbacks.showInformationMessage( + `Kilo Code successfully configured! Authenticated as ${userEmail}`, + ) + + // Clean up + this.deviceAuthService?.dispose() + this.deviceAuthService = undefined + }) + + this.deviceAuthService.on("denied", () => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthFailed", + deviceAuthError: "Authorization was denied", + }) + + this.deviceAuthService?.dispose() + this.deviceAuthService = undefined + }) + + this.deviceAuthService.on("expired", () => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthFailed", + deviceAuthError: "Authorization code expired. Please try again.", + }) + + this.deviceAuthService?.dispose() + this.deviceAuthService = undefined + }) + + this.deviceAuthService.on("error", (error: any) => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthFailed", + deviceAuthError: error.message, + }) + + this.deviceAuthService?.dispose() + this.deviceAuthService = undefined + }) + + this.deviceAuthService.on("cancelled", () => { + this.callbacks.postMessageToWebview({ + type: "deviceAuthCancelled", + }) + }) + + // Start the auth flow + await this.deviceAuthService.initiate() + } catch (error) { + this.callbacks.log(`Error starting device auth: ${error instanceof Error ? error.message : String(error)}`) + + this.callbacks.postMessageToWebview({ + type: "deviceAuthFailed", + deviceAuthError: error instanceof Error ? error.message : "Failed to start authentication", + }) + + this.deviceAuthService?.dispose() + this.deviceAuthService = undefined + } + } + + /** + * Cancel the device authorization flow + */ + cancelDeviceAuth(): void { + if (this.deviceAuthService) { + this.deviceAuthService.cancel() + // Clean up the service after cancellation + // Use setTimeout to avoid disposing during event emission + setTimeout(() => { + if (this.deviceAuthService) { + this.deviceAuthService.dispose() + this.deviceAuthService = undefined + } + }, 0) + } + } + + /** + * Clean up resources + */ + dispose(): void { + if (this.deviceAuthService) { + this.deviceAuthService.dispose() + this.deviceAuthService = undefined + } + } +} diff --git a/src/core/kilocode/webview/webviewMessageHandlerUtils.ts b/src/core/kilocode/webview/webviewMessageHandlerUtils.ts index 32019fd8c57..0b77326b392 100644 --- a/src/core/kilocode/webview/webviewMessageHandlerUtils.ts +++ b/src/core/kilocode/webview/webviewMessageHandlerUtils.ts @@ -247,3 +247,59 @@ export const editMessageHandler = async (provider: ClineProvider, message: Webvi } return } + +/** + * Handles device authentication webview messages + * Supports: startDeviceAuth, cancelDeviceAuth, deviceAuthCompleteWithProfile + */ +export const deviceAuthMessageHandler = async (provider: ClineProvider, message: WebviewMessage): Promise => { + switch (message.type) { + case "startDeviceAuth": { + await provider.startDeviceAuth() + return true + } + case "cancelDeviceAuth": { + provider.cancelDeviceAuth() + return true + } + case "deviceAuthCompleteWithProfile": { + // Save token to specific profile or current profile if no profile name provided + if (message.values?.token) { + const profileName = message.text || undefined // Empty string becomes undefined + const token = message.values.token as string + try { + if (profileName) { + // Save to specified profile + const { ...profileConfig } = await provider.providerSettingsManager.getProfile({ + name: profileName, + }) + await provider.upsertProviderProfile( + profileName, + { + ...profileConfig, + apiProvider: "kilocode", + kilocodeToken: token, + }, + false, // Don't activate - just save + ) + } else { + // Save to current profile (from welcome screen) + const { apiConfiguration, currentApiConfigName = "default" } = await provider.getState() + await provider.upsertProviderProfile(currentApiConfigName, { + ...apiConfiguration, + apiProvider: "kilocode", + kilocodeToken: token, + }) + } + } catch (error) { + provider.log( + `Error saving device auth token: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + return true + } + default: + return false + } +} diff --git a/src/core/kilocode/wrapper.ts b/src/core/kilocode/wrapper.ts index 73d12a882c2..50efbd189eb 100644 --- a/src/core/kilocode/wrapper.ts +++ b/src/core/kilocode/wrapper.ts @@ -8,12 +8,14 @@ export const getKiloCodeWrapperProperties = (): KiloCodeWrapperProperties => { let kiloCodeWrapperTitle = null let kiloCodeWrapperCode = null let kiloCodeWrapperVersion = null + let kiloCodeWrapperJetbrains = false if (kiloCodeWrapped) { const wrapperMatch = appName.split("|") kiloCodeWrapper = wrapperMatch[1].trim() || null kiloCodeWrapperCode = wrapperMatch[2].trim() || null kiloCodeWrapperVersion = wrapperMatch[3].trim() || null + kiloCodeWrapperJetbrains = kiloCodeWrapperCode !== "cli" kiloCodeWrapperTitle = kiloCodeWrapperCode === "cli" ? "Kilo Code CLI" @@ -26,5 +28,6 @@ export const getKiloCodeWrapperProperties = (): KiloCodeWrapperProperties => { kiloCodeWrapperTitle, kiloCodeWrapperCode, kiloCodeWrapperVersion, + kiloCodeWrapperJetbrains, } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a39d1eae321..2b08dcbc630 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -110,6 +110,7 @@ import { getKiloCodeWrapperProperties } from "../../core/kilocode/wrapper" import { getKilocodeConfig, KilocodeConfig } from "../../utils/kilo-config-file" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import { kilo_execIfExtension } from "../../shared/kilocode/cli-sessions/extension/session-manager-utils" +import { DeviceAuthHandler } from "../kilocode/webview/deviceAuthHandler" export type ClineProviderState = Awaited> // kilocode_change end @@ -157,6 +158,7 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private autoPurgeScheduler?: any // kilocode_change - (Any) Prevent circular import + private deviceAuthHandler?: DeviceAuthHandler // kilocode_change - Device auth handler private recentTasksCache?: string[] private pendingOperations: Map = new Map() @@ -682,7 +684,7 @@ export class ClineProvider this.marketplaceManager?.cleanup() this.customModesManager?.dispose() - // kilocode_change start - Stop auto-purge scheduler + // kilocode_change start - Stop auto-purge scheduler and device auth service if (this.autoPurgeScheduler) { this.autoPurgeScheduler.stop() this.autoPurgeScheduler = undefined @@ -1747,7 +1749,7 @@ ${prompt} await this.upsertProviderProfile(profileName, newConfiguration) } - // kilocode_change: + // kilocode_change start async handleKiloCodeCallback(token: string) { const kilocode: ProviderName = "kilocode" let { apiConfiguration, currentApiConfigName = "default" } = await this.getState() @@ -1767,6 +1769,24 @@ ${prompt} }) } } + // kilocode_change end + + // kilocode_change start - Device Auth Flow + async startDeviceAuth() { + if (!this.deviceAuthHandler) { + this.deviceAuthHandler = new DeviceAuthHandler({ + postMessageToWebview: (msg) => this.postMessageToWebview(msg), + log: (msg) => this.log(msg), + showInformationMessage: (msg) => vscode.window.showInformationMessage(msg), + }) + } + await this.deviceAuthHandler.startDeviceAuth() + } + + cancelDeviceAuth() { + this.deviceAuthHandler?.cancelDeviceAuth() + } + // kilocode_change end // Task history diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c21846d4c2c..8785c40da7a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -81,7 +81,13 @@ import { generateSystemPrompt } from "./generateSystemPrompt" import { getCommand } from "../../utils/commands" import { toggleWorkflow, toggleRule, createRuleFile, deleteRuleFile } from "./kilorules" import { mermaidFixPrompt } from "../prompts/utilities/mermaid" // kilocode_change -import { editMessageHandler, fetchKilocodeNotificationsHandler } from "../kilocode/webview/webviewMessageHandlerUtils" // kilocode_change +// kilocode_change start +import { + editMessageHandler, + fetchKilocodeNotificationsHandler, + deviceAuthMessageHandler, +} from "../kilocode/webview/webviewMessageHandlerUtils" +// kilocode_change end const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -3692,7 +3698,19 @@ export const webviewMessageHandler = async ( await provider.postMessageToWebview({ type: "keybindingsResponse", keybindings: {} }) } break + } // kilocode_change start: Chat text area FIM autocomplete + case "requestChatCompletion": { + const { handleChatCompletionRequest } = await import( + "../../services/ghost/chat-autocomplete/handleChatCompletionRequest" + ) + await handleChatCompletionRequest( + message as WebviewMessage & { type: "requestChatCompletion" }, + provider, + getCurrentCwd, + ) + break } + // kilocode_change end: Chat text area FIM autocomplete case "openCommandFile": { try { if (message.text) { @@ -4121,6 +4139,14 @@ export const webviewMessageHandler = async ( break } // kilocode_change end + // kilocode_change start - Device Auth handlers + case "startDeviceAuth": + case "cancelDeviceAuth": + case "deviceAuthCompleteWithProfile": { + await deviceAuthMessageHandler(provider, message) + break + } + // kilocode_change end default: { // console.log(`Unhandled message type: ${message.type}`) // diff --git a/src/extension.ts b/src/extension.ts index eaec1c82326..1cb16bfed6b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -359,11 +359,13 @@ export async function activate(context: vscode.ExtensionContext) { false, ) - // Enable autocomplete by default for new installs + // Enable autocomplete by default for new installs, but not for JetBrains IDEs + // JetBrains users can manually enable it if they want to test the feature + const { kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() const currentGhostSettings = contextProxy.getValue("ghostServiceSettings") await contextProxy.setValue("ghostServiceSettings", { ...currentGhostSettings, - enableAutoTrigger: true, + enableAutoTrigger: !kiloCodeWrapperJetbrains, enableQuickInlineTaskKeybinding: true, enableSmartInlineTaskKeybinding: true, }) @@ -460,14 +462,15 @@ export async function activate(context: vscode.ExtensionContext) { ) // kilocode_change start - Kilo Code specific registrations - const { kiloCodeWrapped } = getKiloCodeWrapperProperties() - if (!kiloCodeWrapped) { - // Only use autocomplete in VS Code - registerGhostProvider(context, provider) - } else { + const { kiloCodeWrapped, kiloCodeWrapperCode } = getKiloCodeWrapperProperties() + if (kiloCodeWrapped) { // Only foward logs in Jetbrains registerMainThreadForwardingLogger(context) } + // Don't register the ghost provider for the CLI + if (kiloCodeWrapperCode !== "cli") { + registerGhostProvider(context, provider) + } registerCommitMessageProvider(context, outputChannel) // kilocode_change // kilocode_change end - Kilo Code specific registrations diff --git a/src/services/ghost/GhostJetbrainsBridge.ts b/src/services/ghost/GhostJetbrainsBridge.ts new file mode 100644 index 00000000000..f35bcab5701 --- /dev/null +++ b/src/services/ghost/GhostJetbrainsBridge.ts @@ -0,0 +1,291 @@ +// kilocode_change - new file +import * as vscode from "vscode" +import { z } from "zod" +import { GhostServiceManager } from "./GhostServiceManager" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { getKiloCodeWrapperProperties } from "../../core/kilocode/wrapper" +import { languageForFilepath } from "../continuedev/core/autocomplete/constants/AutocompleteLanguageInfo" +import { GhostContextProvider } from "./types" +import { FimPromptBuilder } from "./classic-auto-complete/FillInTheMiddle" +import { HoleFiller } from "./classic-auto-complete/HoleFiller" +import { MockTextDocument } from "../mocking/MockTextDocument" + +const GET_INLINE_COMPLETIONS_COMMAND = "kilo-code.jetbrains.getInlineCompletions" + +// Zod schemas for validation +const PositionSchema = z.object({ + line: z.number().int().nonnegative(), + character: z.number().int().nonnegative(), +}) + +const InlineCompletionArgsSchema = z.tuple([ + z.union([z.string(), z.any()]).transform((val) => String(val)), // documentUri - coerce to string + z.union([PositionSchema, z.any()]), // position (can be object or any) + z.union([z.string(), z.any()]).transform((val) => String(val)), // fileContent - coerce to string + z.union([z.string(), z.any()]).transform((val) => String(val)), // languageId - coerce to string + z.union([z.string(), z.any()]).transform((val) => String(val)), // requestId - coerce to string +]) + +type InlineCompletionArgs = z.infer + +interface DocumentParams { + uri: string + position: { line: number; character: number } + content: string + languageId: string + requestId: string +} + +interface NormalizedContent { + normalizedContent: string + lines: string[] +} + +interface CompletionResult { + requestId: string + items: Array<{ + insertText: string + range: { + start: { line: number; character: number } + end: { line: number; character: number } + } | null + }> + error: string | null +} + +export class GhostJetbrainsBridge { + private ghost: GhostServiceManager + + constructor(ghost: GhostServiceManager) { + this.ghost = ghost + } + + private determineLanguage(langId: string, uri: string): string { + // If we have a valid language ID that's not generic, use it + if (langId && langId !== "text" && langId !== "textmate") { + return langId + } + + // Use the languageForFilepath function to get language info from file extension + const languageInfo = languageForFilepath(uri) + const languageName = languageInfo.name.toLowerCase() + + // Map language names to VSCode language IDs + const languageIdMap: { [key: string]: string } = { + typescript: "typescript", + javascript: "javascript", + python: "python", + java: "java", + "c++": "cpp", + "c#": "csharp", + c: "c", + scala: "scala", + go: "go", + rust: "rust", + haskell: "haskell", + php: "php", + ruby: "ruby", + "ruby on rails": "ruby", + swift: "swift", + kotlin: "kotlin", + clojure: "clojure", + julia: "julia", + "f#": "fsharp", + r: "r", + dart: "dart", + solidity: "solidity", + yaml: "yaml", + json: "json", + markdown: "markdown", + lua: "lua", + } + + return languageIdMap[languageName] || languageName + } + + /** + * Parse and validate the RPC arguments using Zod schemas + */ + private parseAndValidateArgs(...args: any[]): DocumentParams { + // RPC passes all arguments as a single array in args[0] + const argsArray = Array.isArray(args[0]) ? args[0] : args + + // Parse with Zod schema + const parsed = InlineCompletionArgsSchema.parse(argsArray) + const [documentUri, position, fileContent, languageId, requestId] = parsed + + // Safely extract and normalize parameters + const uri = typeof documentUri === "string" ? documentUri : String(documentUri) + const pos = + typeof position === "object" && position !== null && "line" in position && "character" in position + ? { line: position.line, character: position.character } + : { line: 0, character: 0 } + const content = typeof fileContent === "string" ? fileContent : String(fileContent) + const langId = typeof languageId === "string" ? languageId : String(languageId || "") + const reqId = typeof requestId === "string" ? requestId : String(requestId || "") + + return { + uri, + position: pos, + content, + languageId: langId, + requestId: reqId, + } + } + + /** + * Normalize content line endings to LF for consistent processing + * JetBrains may send content with different line endings + */ + private normalizeContent(content: string): NormalizedContent { + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const lines = normalizedContent.split("\n") + + return { + normalizedContent, + lines, + } + } + + /** + * Create a mock VSCode TextDocument from the provided parameters + */ + private createMockDocument(uri: string, normalizedContent: string, language: string): vscode.TextDocument { + const mockDocument = new MockTextDocument(vscode.Uri.parse(uri), normalizedContent) + mockDocument.languageId = language + mockDocument.fileName = uri + return mockDocument + } + + /** + * Create a mock context provider that prevents workspace file access. + * This is used for JetBrains bridge to ensure only the provided document content is used. + */ + private createMockContextProvider(normalizedContent: string): GhostContextProvider { + // Access the model through the inline completion provider which has access to it + const provider = this.ghost.inlineCompletionProvider as any + const model = provider.model + + return { + ide: { + readFile: async () => normalizedContent, + getWorkspaceDirs: async () => [], + getClipboardContent: async () => ({ text: "", copiedAt: new Date().toISOString() }), + }, + contextService: { + initializeForFile: async () => {}, + getRootPathSnippets: async () => [], + getSnippetsFromImportDefinitions: async () => [], + getStaticContextSnippets: async () => [], + }, + model, + ignoreController: undefined, + } as unknown as GhostContextProvider + } + + /** + * Serialize completion results to a format suitable for RPC response + */ + private serializeCompletionResult( + completions: vscode.InlineCompletionItem[] | vscode.InlineCompletionList | undefined, + requestId: string, + ): CompletionResult { + const items = Array.isArray(completions) ? completions : completions?.items || [] + + return { + requestId, + items: items.map((item) => ({ + insertText: typeof item.insertText === "string" ? item.insertText : item.insertText.value, + range: item.range + ? { + start: { + line: item.range.start.line, + character: item.range.start.character, + }, + end: { line: item.range.end.line, character: item.range.end.character }, + } + : null, + })), + error: null, + } + } + + public async getInlineCompletions(...args: any[]): Promise { + try { + // Parse and validate arguments + const params = this.parseAndValidateArgs(...args) + + // Normalize content + const { normalizedContent, lines } = this.normalizeContent(params.content) + + // Determine language from languageId or file extension + const language = this.determineLanguage(params.languageId, params.uri) + + // Create mock document + const mockDocument = this.createMockDocument(params.uri, normalizedContent, language) + + // Create VSCode position and context + const vscodePosition = new vscode.Position(params.position.line, params.position.character) + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Invoke, + selectedCompletionInfo: undefined, + } + const tokenSource = new vscode.CancellationTokenSource() + + // Create mock context provider to prevent workspace file access + const mockContextProvider = this.createMockContextProvider(normalizedContent) + + // Save original builders + const originalFimBuilder = this.ghost.inlineCompletionProvider.fimPromptBuilder + const originalHoleFiller = this.ghost.inlineCompletionProvider.holeFiller + + try { + // Temporarily replace builders with ones using mock context + this.ghost.inlineCompletionProvider.fimPromptBuilder = new FimPromptBuilder(mockContextProvider) + this.ghost.inlineCompletionProvider.holeFiller = new HoleFiller(mockContextProvider) + + // Get completions from the provider (will use mock builders internally) + const completions = await this.ghost.inlineCompletionProvider.provideInlineCompletionItems( + mockDocument, + vscodePosition, + context, + tokenSource.token, + ) + + // Serialize and return the result + return this.serializeCompletionResult(completions, params.requestId) + } finally { + // Always restore original builders + this.ghost.inlineCompletionProvider.fimPromptBuilder = originalFimBuilder + this.ghost.inlineCompletionProvider.holeFiller = originalHoleFiller + tokenSource.dispose() + } + } catch (error) { + return { + requestId: "", + items: [], + error: error instanceof Error ? error.message : String(error), + } + } + } +} + +export const registerGhostJetbrainsBridge = ( + context: vscode.ExtensionContext, + _cline: ClineProvider, + ghost: GhostServiceManager, +) => { + // Check if we are running inside JetBrains IDE + const { kiloCodeWrapped, kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() + if (!kiloCodeWrapped || !kiloCodeWrapperJetbrains) { + return + } + + // Initialize the JetBrains Bridge + const bridge = new GhostJetbrainsBridge(ghost) + + // Register JetBrains inline completion command + context.subscriptions.push( + vscode.commands.registerCommand(GET_INLINE_COMPLETIONS_COMMAND, bridge.getInlineCompletions.bind(bridge)), + ) +} diff --git a/src/services/ghost/GhostServiceManager.ts b/src/services/ghost/GhostServiceManager.ts index 69b52b1a468..6a96dae85a7 100644 --- a/src/services/ghost/GhostServiceManager.ts +++ b/src/services/ghost/GhostServiceManager.ts @@ -9,6 +9,7 @@ import { GhostServiceSettings, TelemetryEventName } from "@roo-code/types" import { ContextProxy } from "../../core/config/ContextProxy" import { TelemetryService } from "@roo-code/telemetry" import { ClineProvider } from "../../core/webview/ClineProvider" +import { getKiloCodeWrapperProperties } from "../../core/kilocode/wrapper" export class GhostServiceManager { private readonly model: GhostModel @@ -57,10 +58,11 @@ export class GhostServiceManager { enableQuickInlineTaskKeybinding: true, enableSmartInlineTaskKeybinding: true, } - // 1% rollout: auto-enable autocomplete for a small subset of logged-in KiloCode users - // who have never explicitly toggled enableAutoTrigger. + // Auto-enable autocomplete by default, but disable for JetBrains IDEs + // JetBrains users can manually enable it if they want to test the feature if (this.settings.enableAutoTrigger == undefined) { - this.settings.enableAutoTrigger = true + const { kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() + this.settings.enableAutoTrigger = !kiloCodeWrapperJetbrains } await this.updateGlobalContext() diff --git a/src/services/ghost/__tests__/GhostJetbrainsBridge.spec.ts b/src/services/ghost/__tests__/GhostJetbrainsBridge.spec.ts new file mode 100644 index 00000000000..de377ca727b --- /dev/null +++ b/src/services/ghost/__tests__/GhostJetbrainsBridge.spec.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as vscode from "vscode" +import { GhostJetbrainsBridge } from "../GhostJetbrainsBridge" +import { GhostServiceManager } from "../GhostServiceManager" + +// Mock vscode module +vi.mock("vscode", () => ({ + Uri: { + parse: vi.fn((uri: string) => ({ toString: () => uri, fsPath: uri })), + }, + Position: class Position { + constructor( + public line: number, + public character: number, + ) {} + }, + Range: class Range { + constructor( + public start: any, + public end: any, + ) {} + }, + EndOfLine: { + LF: 1, + CRLF: 2, + }, + InlineCompletionTriggerKind: { + Invoke: 0, + Automatic: 1, + }, + CancellationTokenSource: class CancellationTokenSource { + token = { isCancellationRequested: false } + dispose = vi.fn() + }, +})) + +describe("GhostJetbrainsBridge", () => { + let bridge: GhostJetbrainsBridge + let mockGhost: any + + beforeEach(() => { + mockGhost = { + inlineCompletionProvider: { + provideInlineCompletionItems: vi.fn().mockResolvedValue([ + { + insertText: "console.log('test')", + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }, + ]), + fimPromptBuilder: {}, + holeFiller: {}, + }, + } as any + + bridge = new GhostJetbrainsBridge(mockGhost) + }) + + describe("parseAndValidateArgs", () => { + it("should parse arguments when passed as array", async () => { + const args = [["file:///test.ts", { line: 5, character: 10 }, "const x = 1", "typescript", "req-123"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.requestId).toBe("req-123") + expect(result.error).toBeNull() + }) + + it("should parse arguments when passed separately", async () => { + const args = ["file:///test.ts", { line: 5, character: 10 }, "const x = 1", "typescript", "req-456"] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.requestId).toBe("req-456") + expect(result.error).toBeNull() + }) + + it("should handle invalid position gracefully", async () => { + const args = [["file:///test.ts", null, "const x = 1", "typescript", "req-789"]] + + const result = await bridge.getInlineCompletions(...args) + + // Should default to position 0,0 + expect(mockGhost.inlineCompletionProvider.provideInlineCompletionItems).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ line: 0, character: 0 }), + expect.anything(), + expect.anything(), + ) + }) + + it("should convert non-string values to strings", async () => { + const args = [[123 as any, { line: 0, character: 0 }, 456 as any, 789 as any, 999 as any]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.error).toBeNull() + }) + }) + + describe("normalizeContent", () => { + it("should normalize CRLF line endings to LF", async () => { + const content = "line1\r\nline2\r\nline3" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-1"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + expect(mockDocument.getText()).toBe("line1\nline2\nline3") + }) + + it("should normalize CR line endings to LF", async () => { + const content = "line1\rline2\rline3" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-2"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + expect(mockDocument.getText()).toBe("line1\nline2\nline3") + }) + + it("should handle mixed line endings", async () => { + const content = "line1\r\nline2\rline3\nline4" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-3"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + expect(mockDocument.getText()).toBe("line1\nline2\nline3\nline4") + }) + }) + + describe("createMockDocument", () => { + it("should create a valid TextDocument mock", async () => { + const content = "line1\nline2\nline3" + const args = [["file:///test.ts", { line: 1, character: 5 }, content, "typescript", "req-4"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + + expect(mockDocument.languageId).toBe("typescript") + expect(mockDocument.lineCount).toBe(3) + expect(mockDocument.getText()).toBe(content) + }) + + it("should implement getText with range correctly", async () => { + const content = "line1\nline2\nline3" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-5"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + + // Test single line range + const range1 = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 5)) + expect(mockDocument.getText(range1)).toBe("line1") + + // Test multi-line range + const range2 = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(1, 5)) + expect(mockDocument.getText(range2)).toBe("line1\nline2") + }) + + it("should implement lineAt correctly", async () => { + const content = " const x = 1\nlet y = 2\n" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-6"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + + const line = mockDocument.lineAt(0) + expect(line.text).toBe(" const x = 1") + expect(line.firstNonWhitespaceCharacterIndex).toBe(2) + expect(line.isEmptyOrWhitespace).toBe(false) + }) + + it("should implement offsetAt correctly", async () => { + const content = "abc\ndef\nghi" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-7"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + + // Position at start of line 0 + expect(mockDocument.offsetAt(new vscode.Position(0, 0))).toBe(0) + // Position at end of line 0 + expect(mockDocument.offsetAt(new vscode.Position(0, 3))).toBe(3) + // Position at start of line 1 + expect(mockDocument.offsetAt(new vscode.Position(1, 0))).toBe(4) + }) + + it("should implement positionAt correctly", async () => { + const content = "abc\ndef\nghi" + const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-8"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + + // Offset 0 should be position 0,0 + const pos1 = mockDocument.positionAt(0) + expect(pos1.line).toBe(0) + expect(pos1.character).toBe(0) + + // Offset 4 should be position 1,0 (start of second line) + const pos2 = mockDocument.positionAt(4) + expect(pos2.line).toBe(1) + expect(pos2.character).toBe(0) + }) + }) + + describe("serializeCompletionResult", () => { + it("should serialize completion items correctly", async () => { + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-9"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.requestId).toBe("req-9") + expect(result.items).toHaveLength(1) + expect(result.items[0].insertText).toBe("console.log('test')") + expect(result.items[0].range).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }) + expect(result.error).toBeNull() + }) + + it("should handle completions with string insertText", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue([ + { + insertText: "simple string", + range: null, + }, + ]) + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-10"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.items[0].insertText).toBe("simple string") + expect(result.items[0].range).toBeNull() + }) + + it("should handle completions with SnippetString insertText", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue([ + { + insertText: { value: "snippet value" }, + range: null, + }, + ]) + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-11"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.items[0].insertText).toBe("snippet value") + }) + + it("should handle InlineCompletionList format", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue({ + items: [ + { + insertText: "from list", + range: null, + }, + ], + }) + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-12"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.items).toHaveLength(1) + expect(result.items[0].insertText).toBe("from list") + }) + + it("should handle empty completions", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue([]) + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-13"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.items).toHaveLength(0) + expect(result.error).toBeNull() + }) + + it("should handle undefined completions", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue(undefined) + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-14"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.items).toHaveLength(0) + expect(result.error).toBeNull() + }) + }) + + describe("error handling", () => { + it("should return error result when validation fails", async () => { + const args = [[]] // Invalid args + + const result = await bridge.getInlineCompletions(...args) + + expect(result.requestId).toBe("") + expect(result.items).toHaveLength(0) + expect(result.error).toBeTruthy() + }) + + it("should return error result when provider throws", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockRejectedValue( + new Error("Provider error"), + ) + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-15"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.requestId).toBe("") + expect(result.items).toHaveLength(0) + expect(result.error).toBe("Provider error") + }) + + it("should handle non-Error exceptions", async () => { + mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mockRejectedValue("String error") + + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-16"]] + + const result = await bridge.getInlineCompletions(...args) + + expect(result.error).toBe("String error") + }) + }) + + describe("language determination", () => { + it("should use provided languageId when valid", async () => { + const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-17"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + expect(mockDocument.languageId).toBe("typescript") + }) + + it("should determine language from file extension when languageId is generic", async () => { + const args = [["file:///test.py", { line: 0, character: 0 }, "x = 1", "text", "req-18"]] + + await bridge.getInlineCompletions(...args) + + const mockDocument = mockGhost.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0] + expect(mockDocument.languageId).toBe("python") + }) + }) +}) diff --git a/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts b/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts new file mode 100644 index 00000000000..2b107e9860e --- /dev/null +++ b/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -0,0 +1,154 @@ +import * as vscode from "vscode" +import { GhostModel } from "../GhostModel" +import { ProviderSettingsManager } from "../../../core/config/ProviderSettingsManager" +import { VisibleCodeContext } from "../types" + +/** + * Service for providing FIM-based autocomplete suggestions in ChatTextArea + */ +export class ChatTextAreaAutocomplete { + private model: GhostModel + private providerSettingsManager: ProviderSettingsManager + + constructor(providerSettingsManager: ProviderSettingsManager) { + this.model = new GhostModel() + this.providerSettingsManager = providerSettingsManager + } + + async initialize(): Promise { + return this.model.reload(this.providerSettingsManager) + } + + /** + * Check if we can successfully make a FIM request. + * Validates that model is loaded, has valid API handler, and supports FIM. + */ + isFimAvailable(): boolean { + return this.model.hasValidCredentials() && this.model.supportsFim() + } + + async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> { + if (!this.model.loaded) { + const loaded = await this.initialize() + if (!loaded) { + return { suggestion: "" } + } + } + + // Now check if we can make FIM requests + if (!this.isFimAvailable()) { + return { suggestion: "" } + } + + const prefix = await this.buildPrefix(userText, visibleCodeContext) + const suffix = "" + + let response = "" + await this.model.generateFimResponse(prefix, suffix, (chunk) => { + response += chunk + }) + + const cleanedSuggestion = this.cleanSuggestion(response, userText) + + return { suggestion: cleanedSuggestion } + } + + /** + * Build the prefix for FIM completion with visible code context and additional sources + */ + private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise { + const contextParts: string[] = [] + + // Add visible code context (replaces cursor-based prefix/suffix) + if (visibleCodeContext && visibleCodeContext.editors.length > 0) { + contextParts.push("// Code visible in editor:") + + for (const editor of visibleCodeContext.editors) { + const fileName = editor.filePath.split("/").pop() || editor.filePath + contextParts.push(`\n// File: ${fileName} (${editor.languageId})`) + + for (const range of editor.visibleRanges) { + contextParts.push(range.content) + } + } + } + + const clipboardContent = await this.getClipboardContext() + if (clipboardContent) { + contextParts.push("\n// Clipboard content:") + contextParts.push(clipboardContent) + } + + contextParts.push("\n// User's message:") + contextParts.push(userText) + + return contextParts.join("\n") + } + + /** + * Get clipboard content for context + */ + private async getClipboardContext(): Promise { + try { + const text = await vscode.env.clipboard.readText() + // Only include if it's reasonable size and looks like code + if (text && text.length > 5 && text.length < 500) { + return text + } + } catch { + // Silently ignore clipboard errors + } + return null + } + + /** + * Clean the suggestion by removing any leading repetition of user text + * and filtering out unwanted patterns like comments + */ + private cleanSuggestion(suggestion: string, userText: string): string { + let cleaned = suggestion.trim() + + if (cleaned.startsWith(userText)) { + cleaned = cleaned.substring(userText.length) + } + + const firstNewline = cleaned.indexOf("\n") + if (firstNewline !== -1) { + cleaned = cleaned.substring(0, firstNewline) + } + + cleaned = cleaned.trimStart() + + // Filter out suggestions that start with comment patterns + // This happens because the context uses // prefixes for labels + if (this.isUnwantedSuggestion(cleaned)) { + return "" + } + + return cleaned + } + + /** + * Check if suggestion should be filtered out + */ + public isUnwantedSuggestion(suggestion: string): boolean { + // Filter comment-starting suggestions + if (suggestion.startsWith("//") || suggestion.startsWith("/*") || suggestion.startsWith("*")) { + return true + } + + // Filter suggestions that look like code rather than natural language + // This includes preprocessor directives (#include) and markdown headers + // Chat is for natural language, not formatted documents + if (suggestion.startsWith("#")) { + return true + } + + // Filter suggestions that are just punctuation or whitespace + if (suggestion.length < 2 || /^[\s\p{P}]+$/u.test(suggestion)) { + return true + } + + return false + } +} diff --git a/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts b/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts new file mode 100644 index 00000000000..455bafe1125 --- /dev/null +++ b/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts @@ -0,0 +1,55 @@ +import { ChatTextAreaAutocomplete } from "../ChatTextAreaAutocomplete" +import { ProviderSettingsManager } from "../../../../core/config/ProviderSettingsManager" + +describe("ChatTextAreaAutocomplete", () => { + let autocomplete: ChatTextAreaAutocomplete + let mockProviderSettingsManager: ProviderSettingsManager + + beforeEach(() => { + mockProviderSettingsManager = {} as ProviderSettingsManager + autocomplete = new ChatTextAreaAutocomplete(mockProviderSettingsManager) + }) + + describe("isFimAvailable", () => { + it("should return false when model is not loaded", () => { + const result = autocomplete.isFimAvailable() + expect(result).toBe(false) + }) + }) + + describe("isUnwantedSuggestion", () => { + it("should filter code patterns (comments, preprocessor, short/empty)", () => { + const filter = autocomplete.isUnwantedSuggestion.bind(autocomplete) + + // Comments + expect(filter("// comment")).toBe(true) + expect(filter("/* comment")).toBe(true) + expect(filter("*")).toBe(true) + + // Code patterns + expect(filter("#include")).toBe(true) + expect(filter("# Header")).toBe(true) + + // Meaningless content + expect(filter("")).toBe(true) + expect(filter("a")).toBe(true) + expect(filter("...")).toBe(true) + }) + + it("should accept natural language suggestions", () => { + const filter = autocomplete.isUnwantedSuggestion.bind(autocomplete) + + expect(filter("Hello world")).toBe(false) + expect(filter("Can you help me")).toBe(false) + expect(filter("test123")).toBe(false) + expect(filter("What's up?")).toBe(false) + }) + + it("should accept symbols in middle of text", () => { + const filter = autocomplete.isUnwantedSuggestion.bind(autocomplete) + + expect(filter("Text with # in middle")).toBe(false) + expect(filter("Hello // but not a comment")).toBe(false) + }) + }) +}) diff --git a/src/services/ghost/chat-autocomplete/handleChatCompletionRequest.ts b/src/services/ghost/chat-autocomplete/handleChatCompletionRequest.ts new file mode 100644 index 00000000000..1a8c9ecebde --- /dev/null +++ b/src/services/ghost/chat-autocomplete/handleChatCompletionRequest.ts @@ -0,0 +1,37 @@ +import { ClineProvider } from "../../../core/webview/ClineProvider" +import { WebviewMessage } from "../../../shared/WebviewMessage" +import { VisibleCodeTracker } from "../context/VisibleCodeTracker" +import { ChatTextAreaAutocomplete } from "./ChatTextAreaAutocomplete" + +/** + * Handles a chat completion request from the webview. + * Captures visible code context and generates a FIM-based autocomplete suggestion. + */ +export async function handleChatCompletionRequest( + message: WebviewMessage & { type: "requestChatCompletion" }, + provider: ClineProvider, + getCurrentCwd: () => string, +): Promise { + try { + const userText = message.text || "" + const requestId = message.requestId || "" + + // Pass RooIgnoreController to respect .kilocodeignore patterns + const currentTask = provider.getCurrentTask() + const tracker = new VisibleCodeTracker(getCurrentCwd(), currentTask?.rooIgnoreController ?? null) + + const visibleContext = await tracker.captureVisibleCode() + + const autocomplete = new ChatTextAreaAutocomplete(provider.providerSettingsManager) + const { suggestion } = await autocomplete.getCompletion(userText, visibleContext) + + await provider.postMessageToWebview({ type: "chatCompletionResult", text: suggestion, requestId }) + } catch (error) { + provider.log(`Error getting chat completion: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + await provider.postMessageToWebview({ + type: "chatCompletionResult", + text: "", + requestId: message.requestId || "", + }) + } +} diff --git a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts index c9ff9a41732..1efe567de58 100644 --- a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts +++ b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts @@ -123,8 +123,8 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte public suggestionsHistory: FillInAtCursorSuggestion[] = [] /** Tracks all pending/in-flight requests */ private pendingRequests: PendingRequest[] = [] - private holeFiller: HoleFiller - private fimPromptBuilder: FimPromptBuilder + public holeFiller: HoleFiller // publicly exposed for Jetbrains autocomplete code + public fimPromptBuilder: FimPromptBuilder // publicly exposed for Jetbrains autocomplete code private model: GhostModel private costTrackingCallback: CostTrackingCallback private getSettings: () => GhostServiceSettings | null diff --git a/src/services/ghost/context/VisibleCodeTracker.ts b/src/services/ghost/context/VisibleCodeTracker.ts new file mode 100644 index 00000000000..ad0f40c6a0f --- /dev/null +++ b/src/services/ghost/context/VisibleCodeTracker.ts @@ -0,0 +1,154 @@ +/** + * VisibleCodeTracker - Captures the actual visible code in VS Code editors + * + * This service captures what code is currently visible on the user's screen, + * not just what files are open. It uses the VS Code API to get: + * - All visible text editors (not just tabs) + * - The actual visible line ranges in each editor's viewport + * - Cursor positions and selections + */ + +import * as vscode from "vscode" + +import { toRelativePath } from "../../../utils/path" +import { isSecurityConcern } from "../../continuedev/core/indexing/ignore" +import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" + +import { VisibleCodeContext, VisibleEditorInfo, VisibleRange, DiffInfo } from "../types" + +// Git-related URI schemes that should be captured for diff support +const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"] + +export class VisibleCodeTracker { + private lastContext: VisibleCodeContext | null = null + + constructor( + private workspacePath: string, + private rooIgnoreController: RooIgnoreController | null = null, + ) {} + + /** + * Captures the currently visible code across all visible editors. + * Excludes files matching security patterns or .kilocodeignore rules. + * + * @returns VisibleCodeContext containing information about all visible editors + * and their visible code ranges + */ + public async captureVisibleCode(): Promise { + const editors = vscode.window.visibleTextEditors + const activeUri = vscode.window.activeTextEditor?.document.uri.toString() + + const editorInfos: VisibleEditorInfo[] = [] + + for (const editor of editors) { + const document = editor.document + const scheme = document.uri.scheme + + // Skip non-code documents (output panels, extension host output, etc.) + if (!GIT_SCHEMES.includes(scheme)) { + continue + } + + const filePath = document.uri.fsPath + const relativePath = toRelativePath(filePath, this.workspacePath) + + if (isSecurityConcern(filePath)) { + console.log(`[VisibleCodeTracker] Filtered (security): ${relativePath}`) + continue + } + if (this.rooIgnoreController && !this.rooIgnoreController.validateAccess(relativePath)) { + console.log(`[VisibleCodeTracker] Filtered (.kilocodeignore): ${relativePath}`) + continue + } + + const visibleRanges: VisibleRange[] = [] + + for (const range of editor.visibleRanges) { + const content = document.getText(range) + visibleRanges.push({ + startLine: range.start.line, + endLine: range.end.line, + content, + }) + } + + const isActive = document.uri.toString() === activeUri + + // Extract diff information for git-backed documents + const diffInfo = this.extractDiffInfo(document.uri) + + editorInfos.push({ + filePath, + relativePath, + languageId: document.languageId, + isActive, + visibleRanges, + cursorPosition: editor.selection + ? { + line: editor.selection.active.line, + character: editor.selection.active.character, + } + : null, + selections: editor.selections.map((sel) => ({ + start: { line: sel.start.line, character: sel.start.character }, + end: { line: sel.end.line, character: sel.end.character }, + })), + diffInfo, + }) + } + + this.lastContext = { + timestamp: Date.now(), + editors: editorInfos, + } + + return this.lastContext + } + + /** + * Returns the last captured context, or null if never captured. + */ + public getLastContext(): VisibleCodeContext | null { + return this.lastContext + } + + /** + * Extract diff information from a URI. + * Git URIs typically look like: git:/path/to/file.ts?ref=HEAD~1 + */ + private extractDiffInfo(uri: vscode.Uri): DiffInfo | undefined { + const scheme = uri.scheme + + // Only extract diff info for git-related schemes + if (scheme === "git" || scheme === "gitfs") { + // Parse query parameters for git reference + const query = uri.query + let gitRef: string | undefined + + if (query) { + // Common patterns: ref=HEAD, ref=abc123 + const refMatch = query.match(/ref=([^&]+)/) + if (refMatch) { + gitRef = refMatch[1] + } + } + + return { + scheme, + side: "old", // Git scheme documents are typically the "old" side + gitRef, + originalPath: uri.fsPath, + } + } + + // File scheme in a diff view is the "new" side + // We can't always tell if it's in a diff, so we mark it as new when there's a paired git doc + if (scheme === "file") { + // This will be marked as diffInfo only if we detect it's paired with a git document + // For now, we don't set diffInfo for regular file scheme documents + return undefined + } + + return undefined + } +} diff --git a/src/services/ghost/context/__tests__/VisibleCodeTracker.spec.ts b/src/services/ghost/context/__tests__/VisibleCodeTracker.spec.ts new file mode 100644 index 00000000000..4baac07fae3 --- /dev/null +++ b/src/services/ghost/context/__tests__/VisibleCodeTracker.spec.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" +import { VisibleCodeTracker } from "../VisibleCodeTracker" + +// Mock vscode module +vi.mock("vscode", () => ({ + window: { + visibleTextEditors: [], + activeTextEditor: null, + }, +})) + +vi.mock("../../../../services/continuedev/core/indexing/ignore", () => ({ + isSecurityConcern: vi.fn((filePath: string) => { + return filePath.includes(".env") || filePath.includes("credentials") + }), +})) + +describe("VisibleCodeTracker", () => { + const mockWorkspacePath = "/workspace" + + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks() + }) + + describe("captureVisibleCode", () => { + it("should return empty context when no editors are visible", async () => { + // Mock empty editor list + ;(vscode.window.visibleTextEditors as any) = [] + ;(vscode.window.activeTextEditor as any) = null + + const tracker = new VisibleCodeTracker(mockWorkspacePath) + const context = await tracker.captureVisibleCode() + + expect(context).toEqual({ + timestamp: expect.any(Number), + editors: [], + }) + expect(context.timestamp).toBeGreaterThan(0) + }) + + it("should capture visible editors with file scheme", async () => { + const mockDocument = { + uri: { + fsPath: "/workspace/test.ts", + scheme: "file", + toString: () => "file:///workspace/test.ts", + }, + languageId: "typescript", + getText: vi.fn((range: any) => { + if (range.start.line === 0 && range.end.line === 2) { + return "line 0\nline 1\nline 2" + } + return "" + }), + } + + const mockEditor = { + document: mockDocument, + visibleRanges: [ + { + start: { line: 0, character: 0 }, + end: { line: 2, character: 0 }, + }, + ], + selection: { + active: { line: 1, character: 5 }, + }, + selections: [ + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 10 }, + }, + ], + } + + ;(vscode.window.visibleTextEditors as any) = [mockEditor] + ;(vscode.window.activeTextEditor as any) = mockEditor + + const tracker = new VisibleCodeTracker(mockWorkspacePath) + const context = await tracker.captureVisibleCode() + + expect(context.editors).toHaveLength(1) + expect(context.editors[0]).toMatchObject({ + filePath: "/workspace/test.ts", + relativePath: "test.ts", + languageId: "typescript", + isActive: true, + visibleRanges: [ + { + startLine: 0, + endLine: 2, + content: "line 0\nline 1\nline 2", + }, + ], + cursorPosition: { + line: 1, + character: 5, + }, + }) + }) + + it("should extract diff info for git scheme URIs", async () => { + const mockDocument = { + uri: { + fsPath: "/workspace/test.ts", + scheme: "git", + query: "ref=HEAD~1", + toString: () => "git:///workspace/test.ts?ref=HEAD~1", + }, + languageId: "typescript", + getText: vi.fn(() => "old content"), + } + + const mockEditor = { + document: mockDocument, + visibleRanges: [ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + ], + selection: null, + selections: [], + } + + ;(vscode.window.visibleTextEditors as any) = [mockEditor] + ;(vscode.window.activeTextEditor as any) = null + + const tracker = new VisibleCodeTracker(mockWorkspacePath) + const context = await tracker.captureVisibleCode() + + expect(context.editors[0].diffInfo).toEqual({ + scheme: "git", + side: "old", + gitRef: "HEAD~1", + originalPath: "/workspace/test.ts", + }) + }) + + it("should skip non-code documents", async () => { + const mockOutputDocument = { + uri: { + fsPath: "/workspace/output", + scheme: "output", + toString: () => "output:///workspace/output", + }, + languageId: "plaintext", + getText: vi.fn(() => ""), + } + + const mockEditor = { + document: mockOutputDocument, + visibleRanges: [], + selection: null, + selections: [], + } + + ;(vscode.window.visibleTextEditors as any) = [mockEditor] + ;(vscode.window.activeTextEditor as any) = null + + const tracker = new VisibleCodeTracker(mockWorkspacePath) + const context = await tracker.captureVisibleCode() + + // Output scheme should be filtered out + expect(context.editors).toHaveLength(0) + }) + }) + + describe("security filtering", () => { + it("should filter security-sensitive files", async () => { + const mockEnvDocument = { + uri: { + fsPath: "/workspace/.env", + scheme: "file", + toString: () => "file:///workspace/.env", + }, + languageId: "plaintext", + getText: vi.fn(() => "SECRET_KEY=12345"), + } + + const mockEditor = { + document: mockEnvDocument, + visibleRanges: [{ start: { line: 0, character: 0 }, end: { line: 0, character: 20 } }], + selection: null, + selections: [], + } + + ;(vscode.window.visibleTextEditors as any) = [mockEditor] + ;(vscode.window.activeTextEditor as any) = null + + const tracker = new VisibleCodeTracker(mockWorkspacePath) + const context = await tracker.captureVisibleCode() + + expect(context.editors).toHaveLength(0) + }) + }) + + describe(".kilocodeignore integration", () => { + it("should filter files matching .kilocodeignore patterns", async () => { + const mockIgnoredDocument = { + uri: { + fsPath: "/workspace/sensitive/data.json", + scheme: "file", + toString: () => "file:///workspace/sensitive/data.json", + }, + languageId: "json", + getText: vi.fn(() => '{"data": "sensitive"}'), + } + + const mockEditor = { + document: mockIgnoredDocument, + visibleRanges: [{ start: { line: 0, character: 0 }, end: { line: 0, character: 25 } }], + selection: null, + selections: [], + } + + ;(vscode.window.visibleTextEditors as any) = [mockEditor] + ;(vscode.window.activeTextEditor as any) = null + + const mockController = { + validateAccess: vi.fn((path: string) => { + return !path.includes("sensitive/") + }), + } as any + + const tracker = new VisibleCodeTracker(mockWorkspacePath, mockController) + const context = await tracker.captureVisibleCode() + + expect(context.editors).toHaveLength(0) + expect(mockController.validateAccess).toHaveBeenCalledWith("sensitive/data.json") + }) + }) +}) diff --git a/src/services/ghost/index.ts b/src/services/ghost/index.ts index 4cd8e5616e9..ae8b6d0fba4 100644 --- a/src/services/ghost/index.ts +++ b/src/services/ghost/index.ts @@ -2,11 +2,15 @@ import * as vscode from "vscode" import { GhostServiceManager } from "./GhostServiceManager" import { ClineProvider } from "../../core/webview/ClineProvider" +import { registerGhostJetbrainsBridge } from "./GhostJetbrainsBridge" export const registerGhostProvider = (context: vscode.ExtensionContext, cline: ClineProvider) => { const ghost = new GhostServiceManager(context, cline) context.subscriptions.push(ghost) + // Register JetBrains Bridge if applicable + registerGhostJetbrainsBridge(context, cline, ghost) + // Register GhostServiceManager Commands context.subscriptions.push( vscode.commands.registerCommand("kilo-code.ghost.reload", async () => { diff --git a/src/services/ghost/types.ts b/src/services/ghost/types.ts index dfee3cd84be..b0780b8e2a6 100644 --- a/src/services/ghost/types.ts +++ b/src/services/ghost/types.ts @@ -97,6 +97,10 @@ export interface PromptResult { completionId: string } +// ============================================================================ +// FIM/Hole Filler Completion Types +// ============================================================================ + export interface FillInAtCursorSuggestion { text: string prefix: string @@ -164,6 +168,88 @@ export interface PendingRequest { promise: Promise } +// ============================================================================ +// Visible Code Context Types +// ============================================================================ + +/** + * Visible range in an editor viewport + */ +export interface VisibleRange { + startLine: number + endLine: number + content: string +} + +/** + * Diff metadata for git-backed editors + */ +export interface DiffInfo { + /** The URI scheme (e.g., "git", "gitfs") */ + scheme: string + /** Whether this is the "old" (left) or "new" (right) side of a diff */ + side: "old" | "new" + /** Git reference if available (e.g., "HEAD", "HEAD~1", commit hash) */ + gitRef?: string + /** The actual file path being compared */ + originalPath: string +} + +/** + * Information about a visible editor + */ +export interface VisibleEditorInfo { + /** Absolute file path */ + filePath: string + /** Path relative to workspace */ + relativePath: string + /** Language identifier (e.g., "typescript", "python") */ + languageId: string + /** Whether this is the active editor */ + isActive: boolean + /** The visible line ranges in the editor viewport */ + visibleRanges: VisibleRange[] + /** Current cursor position, or null if no cursor */ + cursorPosition: Position | null + /** All selections in the editor */ + selections: Range[] + /** Diff information if this editor is part of a diff view */ + diffInfo?: DiffInfo +} + +/** + * Context of all visible code in editors + */ +export interface VisibleCodeContext { + /** Timestamp when the context was captured */ + timestamp: number + /** Information about all visible editors */ + editors: VisibleEditorInfo[] +} + +// ============================================================================ +// Chat Text Area Autocomplete Types +// ============================================================================ + +/** + * Request for chat text area completion + */ +export interface ChatCompletionRequest { + text: string +} + +/** + * Result of chat text area completion (distinct from code editor ChatCompletionResult) + */ +export interface ChatTextCompletionResult { + suggestion: string + requestId: string +} + +// ============================================================================ +// Conversion Utilities +// ============================================================================ + export function extractPrefixSuffix( document: vscode.TextDocument, position: vscode.Position, diff --git a/src/services/kilocode/DeviceAuthService.ts b/src/services/kilocode/DeviceAuthService.ts new file mode 100644 index 00000000000..adf4cb0e476 --- /dev/null +++ b/src/services/kilocode/DeviceAuthService.ts @@ -0,0 +1,195 @@ +import EventEmitter from "events" +import { getApiUrl, DeviceAuthInitiateResponseSchema, DeviceAuthPollResponseSchema } from "@roo-code/types" +import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "@roo-code/types" + +const POLL_INTERVAL_MS = 3000 + +export interface DeviceAuthServiceEvents { + started: [data: DeviceAuthInitiateResponse] + polling: [timeRemaining: number] + success: [token: string, userEmail: string] + denied: [] + expired: [] + error: [error: Error] + cancelled: [] +} + +/** + * Service for handling device authorization flow + */ +export class DeviceAuthService extends EventEmitter { + private pollIntervalId?: NodeJS.Timeout + private startTime?: number + private expiresIn?: number + private code?: string + private aborted = false + + /** + * Initiate device authorization flow + * @returns Device authorization details + * @throws Error if initiation fails + */ + async initiate(): Promise { + try { + const response = await fetch(getApiUrl("/api/device-auth/codes"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + if (response.status === 429) { + throw new Error("Too many pending authorization requests. Please try again later.") + } + throw new Error(`Failed to initiate device authorization: ${response.status}`) + } + + const data = await response.json() + + // Validate the response against the schema + const validationResult = DeviceAuthInitiateResponseSchema.safeParse(data) + + if (!validationResult.success) { + console.error("[DeviceAuthService] Invalid initiate response format", { + errors: validationResult.error.errors, + }) + // Continue with unvalidated data for graceful degradation + } + + const validatedData = validationResult.success + ? validationResult.data + : (data as DeviceAuthInitiateResponse) + + this.code = validatedData.code + this.expiresIn = validatedData.expiresIn + this.startTime = Date.now() + this.aborted = false + + this.emit("started", validatedData) + + // Start polling + this.startPolling() + + return data + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + this.emit("error", err) + throw err + } + } + + /** + * Poll for device authorization status + */ + private async poll(): Promise { + if (!this.code || this.aborted) { + return + } + + try { + const response = await fetch(getApiUrl(`/api/device-auth/codes/${this.code}`)) + + // Guard against undefined response (can happen in tests or network errors) + if (!response) { + return + } + + if (response.status === 202) { + // Still pending - emit time remaining + if (this.startTime && this.expiresIn) { + const elapsed = Math.floor((Date.now() - this.startTime) / 1000) + const remaining = Math.max(0, this.expiresIn - elapsed) + this.emit("polling", remaining) + } + return + } + + // Stop polling for any non-pending status + this.stopPolling() + + if (response.status === 403) { + // Denied by user + this.emit("denied") + return + } + + if (response.status === 410) { + // Code expired + this.emit("expired") + return + } + + if (!response.ok) { + throw new Error(`Failed to poll device authorization: ${response.status}`) + } + + const data = await response.json() + + // Validate the response against the schema + const validationResult = DeviceAuthPollResponseSchema.safeParse(data) + + if (!validationResult.success) { + console.error("[DeviceAuthService] Invalid poll response format", { + errors: validationResult.error.errors, + }) + // Continue with unvalidated data for graceful degradation + } + + const validatedData = validationResult.success ? validationResult.data : (data as DeviceAuthPollResponse) + + if (validatedData.status === "approved" && validatedData.token && validatedData.userEmail) { + this.emit("success", validatedData.token, validatedData.userEmail) + } else if (validatedData.status === "denied") { + this.emit("denied") + } else if (validatedData.status === "expired") { + this.emit("expired") + } + } catch (error) { + this.stopPolling() + const err = error instanceof Error ? error : new Error(String(error)) + this.emit("error", err) + } + } + + /** + * Start polling for authorization status + */ + private startPolling(): void { + this.stopPolling() + this.pollIntervalId = setInterval(() => { + this.poll() + }, POLL_INTERVAL_MS) + + // Do first poll immediately + this.poll() + } + + /** + * Stop polling for authorization status + */ + private stopPolling(): void { + if (this.pollIntervalId) { + clearInterval(this.pollIntervalId) + this.pollIntervalId = undefined + } + } + + /** + * Cancel the device authorization flow + */ + cancel(): void { + this.aborted = true + this.stopPolling() + this.emit("cancelled") + } + + /** + * Clean up resources + */ + dispose(): void { + this.aborted = true + this.stopPolling() + this.removeAllListeners() + } +} diff --git a/src/services/kilocode/__tests__/DeviceAuthService.test.ts b/src/services/kilocode/__tests__/DeviceAuthService.test.ts new file mode 100644 index 00000000000..15818ac2ff9 --- /dev/null +++ b/src/services/kilocode/__tests__/DeviceAuthService.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { DeviceAuthService } from "../DeviceAuthService" +import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "@roo-code/types" + +// Mock fetch globally +global.fetch = vi.fn() + +describe("DeviceAuthService", () => { + let service: DeviceAuthService + + beforeEach(() => { + service = new DeviceAuthService() + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + service.dispose() + vi.useRealTimers() + }) + + describe("initiate", () => { + it("should successfully initiate device auth", async () => { + const mockResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + const startedSpy = vi.fn() + service.on("started", startedSpy) + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + // Mock the first poll call to return pending + ;(global.fetch as any).mockResolvedValueOnce({ + status: 202, + }) + + const result = await service.initiate() + + expect(result).toEqual(mockResponse) + expect(startedSpy).toHaveBeenCalledWith(mockResponse) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/device-auth/codes"), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }), + ) + }) + + it("should handle rate limiting (429)", async () => { + ;(global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 429, + }) + + const errorSpy = vi.fn() + service.on("error", errorSpy) + + await expect(service.initiate()).rejects.toThrow("Too many pending authorization requests") + expect(errorSpy).toHaveBeenCalled() + }) + + it("should handle other errors", async () => { + ;(global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 500, + }) + + const errorSpy = vi.fn() + service.on("error", errorSpy) + + await expect(service.initiate()).rejects.toThrow("Failed to initiate device authorization: 500") + expect(errorSpy).toHaveBeenCalled() + }) + }) + + describe("polling", () => { + it("should emit polling event for pending status", async () => { + const pollingSpy = vi.fn() + service.on("polling", pollingSpy) + + const mockInitResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + // Mock initiate call + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockInitResponse, + }) + + // Mock all subsequent polls to return pending to prevent infinite loop + ;(global.fetch as any).mockResolvedValue({ + status: 202, + }) + + await service.initiate() + + // Wait for the immediate poll call + await vi.advanceTimersByTimeAsync(100) + + expect(pollingSpy).toHaveBeenCalled() + + // Clean up to prevent background timers + service.cancel() + }) + + it("should emit success event when approved", async () => { + const successSpy = vi.fn() + service.on("success", successSpy) + + const mockInitResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + const mockPollResponse: DeviceAuthPollResponse = { + status: "approved", + token: "test-token", + userEmail: "test@example.com", + } + + // Mock initiate call + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockInitResponse, + }) + + // Mock poll - approved + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockPollResponse, + }) + + await service.initiate() + + // Wait for the immediate poll call + await vi.runAllTimersAsync() + + expect(successSpy).toHaveBeenCalledWith("test-token", "test@example.com") + }) + + it("should emit denied event when user denies", async () => { + const deniedSpy = vi.fn() + service.on("denied", deniedSpy) + + const mockInitResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + // Mock initiate call + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockInitResponse, + }) + + // Mock poll - denied + ;(global.fetch as any).mockResolvedValueOnce({ + status: 403, + }) + + await service.initiate() + + // Wait for the immediate poll call + await vi.runAllTimersAsync() + + expect(deniedSpy).toHaveBeenCalled() + }) + + it("should emit expired event when code expires", async () => { + const expiredSpy = vi.fn() + service.on("expired", expiredSpy) + + const mockInitResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + // Mock initiate call + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockInitResponse, + }) + + // Mock poll - expired + ;(global.fetch as any).mockResolvedValueOnce({ + status: 410, + }) + + await service.initiate() + + // Wait for the immediate poll call + await vi.runAllTimersAsync() + + expect(expiredSpy).toHaveBeenCalled() + }) + + it("should handle polling errors", async () => { + const errorSpy = vi.fn() + service.on("error", errorSpy) + + const mockInitResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + // Mock initiate call + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockInitResponse, + }) + + // Mock poll - error + ;(global.fetch as any).mockRejectedValueOnce(new Error("Network error")) + + await service.initiate() + + // Wait for the immediate poll call + await vi.runAllTimersAsync() + + expect(errorSpy).toHaveBeenCalled() + }) + }) + + describe("cancel", () => { + it("should emit cancelled event and stop polling", async () => { + const cancelledSpy = vi.fn() + service.on("cancelled", cancelledSpy) + + const mockResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + // Mock first poll + ;(global.fetch as any).mockResolvedValueOnce({ + status: 202, + }) + + await service.initiate() + + service.cancel() + + expect(cancelledSpy).toHaveBeenCalled() + + // Verify polling stopped by checking no more fetch calls after cancel + vi.clearAllMocks() + await vi.advanceTimersByTimeAsync(5000) + expect(global.fetch).not.toHaveBeenCalled() + }) + }) + + describe("dispose", () => { + it("should clean up resources", async () => { + const mockResponse: DeviceAuthInitiateResponse = { + code: "ABC123", + verificationUrl: "https://kilo.ai/device/verify", + expiresIn: 600, + } + + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + // Mock first poll + ;(global.fetch as any).mockResolvedValueOnce({ + status: 202, + }) + + await service.initiate() + + service.dispose() + + // Verify polling stopped + vi.clearAllMocks() + await vi.advanceTimersByTimeAsync(5000) + expect(global.fetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 9c5a4a5ba51..ac8744a1796 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -184,7 +184,12 @@ export interface ExtensionMessage { | "taskMetadataSaved" // kilocode_change: File save event for task metadata | "managedIndexerState" // kilocode_change | "singleCompletionResult" // kilocode_change - | "managedIndexerState" // kilocode_change + | "deviceAuthStarted" // kilocode_change: Device auth initiated + | "deviceAuthPolling" // kilocode_change: Device auth polling update + | "deviceAuthComplete" // kilocode_change: Device auth successful + | "deviceAuthFailed" // kilocode_change: Device auth failed + | "deviceAuthCancelled" // kilocode_change: Device auth cancelled + | "chatCompletionResult" // kilocode_change: FIM completion result for chat text area text?: string // kilocode_change start completionRequestId?: string // Correlation ID from request @@ -335,6 +340,15 @@ export interface ExtensionMessage { browserSessionMessages?: ClineMessage[] // For browser session panel updates isBrowserSessionActive?: boolean // For browser session panel updates stepIndex?: number // For browserSessionNavigate: the target step index to display + // kilocode_change start: Device auth data + deviceAuthCode?: string + deviceAuthVerificationUrl?: string + deviceAuthExpiresIn?: number + deviceAuthTimeRemaining?: number + deviceAuthToken?: string + deviceAuthUserEmail?: string + deviceAuthError?: string + // kilocode_change end: Device auth data } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index a74b59e3db5..3c1f3307405 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -270,11 +270,15 @@ export interface WebviewMessage { | "sessionFork" // kilocode_change | "sessionShow" // kilocode_change | "singleCompletion" // kilocode_change + | "startDeviceAuth" // kilocode_change: Start device auth flow + | "cancelDeviceAuth" // kilocode_change: Cancel device auth flow + | "deviceAuthCompleteWithProfile" // kilocode_change: Device auth complete with specific profile + | "requestChatCompletion" // kilocode_change: Request FIM completion for chat text area text?: string completionRequestId?: string // kilocode_change shareId?: string // kilocode_change - for sessionFork editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" | "auth" // kilocode_change disabled?: boolean context?: string dataUri?: string diff --git a/src/shared/id.ts b/src/shared/id.ts new file mode 100644 index 00000000000..e6317b74170 --- /dev/null +++ b/src/shared/id.ts @@ -0,0 +1,7 @@ +/** + * Generate a unique request ID for messages. + * Uses a short random string suitable for request correlation. + */ +export function generateRequestId(): string { + return Math.random().toString(36).substring(2, 9) +} diff --git a/src/shared/kilocode/wrapper.ts b/src/shared/kilocode/wrapper.ts index 9ab366289a7..bc15745744c 100644 --- a/src/shared/kilocode/wrapper.ts +++ b/src/shared/kilocode/wrapper.ts @@ -4,6 +4,7 @@ export interface KiloCodeWrapperProperties { kiloCodeWrapperTitle: string | null kiloCodeWrapperCode: string | null kiloCodeWrapperVersion: string | null + kiloCodeWrapperJetbrains: boolean } export const JETBRAIN_PRODUCTS = { diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 936f32455f0..14f9ca13a8d 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -17,6 +17,7 @@ import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/kilocode/welcome/WelcomeView" // kilocode_change import ProfileView from "./components/kilocode/profile/ProfileView" // kilocode_change +import AuthView from "./components/kilocode/auth/AuthView" // kilocode_change import McpView from "./components/mcp/McpView" import { MarketplaceView } from "./components/marketplace/MarketplaceView" import ModesView from "./components/modes/ModesView" @@ -34,7 +35,7 @@ import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" import { useKiloIdentity } from "./utils/kilocode/useKiloIdentity" import { MemoryWarningBanner } from "./kilocode/MemoryWarningBanner" -type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" | "cloud" | "profile" // kilocode_change: add "profile" +type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" | "cloud" | "profile" | "auth" // kilocode_change: add "profile" and "auth" interface HumanRelayDialogState { isOpen: boolean @@ -114,6 +115,9 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const [authReturnTo, setAuthReturnTo] = useState<"chat" | "settings">("chat") + const [authProfileName, setAuthProfileName] = useState(undefined) + const [settingsEditingProfile, setSettingsEditingProfile] = useState(undefined) const [humanRelayDialogState, setHumanRelayDialogState] = useState({ isOpen: false, @@ -151,7 +155,11 @@ const App = () => { setCurrentSection(undefined) setCurrentMarketplaceTab(undefined) - if (settingsRef.current?.checkUnsaveChanges) { + // kilocode_change: start - Bypass unsaved changes check when navigating to auth tab + if (newTab === "auth") { + setTab(newTab) + } else if (settingsRef.current?.checkUnsaveChanges) { + // kilocode_change: end settingsRef.current.checkUnsaveChanges(() => setTab(newTab)) } else { setTab(newTab) @@ -181,6 +189,19 @@ const App = () => { // Handle switchTab action with tab parameter if (message.action === "switchTab" && message.tab) { const targetTab = message.tab as Tab + // kilocode_change start - Handle auth tab with returnTo and profileName parameters + if (targetTab === "auth") { + if (message.values?.returnTo) { + const returnTo = message.values.returnTo as "chat" | "settings" + setAuthReturnTo(returnTo) + } + if (message.values?.profileName) { + const profileName = message.values.profileName as string + setAuthProfileName(profileName) + setSettingsEditingProfile(profileName) + } + } + // kilocode_change end switchTab(targetTab) // Extract targetSection from values if provided const targetSection = message.values?.section as string | undefined @@ -191,11 +212,27 @@ const App = () => { const newTab = tabsByMessageAction[message.action] const section = message.values?.section as string | undefined const marketplaceTab = message.values?.marketplaceTab as string | undefined + const editingProfile = message.values?.editingProfile as string | undefined // kilocode_change if (newTab) { switchTab(newTab) setCurrentSection(section) setCurrentMarketplaceTab(marketplaceTab) + // kilocode_change start - If navigating to settings with editingProfile, forward it + if (newTab === "settings" && editingProfile) { + // Re-send the message to SettingsView with the editingProfile + setTimeout(() => { + window.postMessage( + { + type: "action", + action: "settingsButtonClicked", + values: { editingProfile }, + }, + "*", + ) + }, 100) + } + // kilocode_change end } } } @@ -304,11 +341,18 @@ const App = () => { {tab === "modes" && switchTab("chat")} />} {tab === "mcp" && switchTab("chat")} />} {tab === "history" && switchTab("chat")} />} + {/* kilocode_change: auth redirect / editingProfile */} {tab === "settings" && ( - switchTab("chat")} targetSection={currentSection} /> // kilocode_change + switchTab("chat")} + targetSection={currentSection} + editingProfile={settingsEditingProfile} + /> )} - {/* kilocode_change: add profileview */} + {/* kilocode_change: add profileview and authview */} {tab === "profile" && switchTab("chat")} />} + {tab === "auth" && } {tab === "marketplace" && ( ( globalWorkflows, // kilocode_change taskHistoryVersion, // kilocode_change clineMessages, + ghostServiceSettings, // kilocode_change } = useExtensionState() // kilocode_change start - autocomplete profile type system @@ -303,6 +306,16 @@ export const ChatTextArea = forwardRef( const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) const [imageWarning, setImageWarning] = useState(null) // kilocode_change + // kilocode_change start: FIM autocomplete ghost text + const { + ghostText, + handleKeyDown: handleGhostTextKeyDown, + handleInputChange: handleGhostTextInputChange, + } = useChatGhostText({ + textAreaRef, + enableChatAutocomplete: ghostServiceSettings?.enableChatAutocomplete ?? false, + }) + // kilocode_change end: FIM autocomplete ghost text // Use custom hook for prompt history navigation const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ @@ -530,6 +543,12 @@ export const ChatTextArea = forwardRef( const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { + // kilocode_change start: FIM autocomplete - Tab to accept ghost text + if (handleGhostTextKeyDown(event)) { + return // Event was handled by ghost text hook, stop here + } + // kilocode_change end: FIM autocomplete + // kilocode_change start: pull slash commands from Cline if (showSlashCommandsMenu) { if (event.key === "Escape") { @@ -720,6 +739,7 @@ export const ChatTextArea = forwardRef( handleSlashCommandsSelect, selectedSlashCommandsIndex, slashCommandsQuery, + handleGhostTextKeyDown, // kilocode_change: FIM autocomplete // kilocode_change end onSend, showContextMenu, @@ -765,7 +785,9 @@ export const ChatTextArea = forwardRef( // Reset history navigation when user types resetOnInputChange() - const newCursorPosition = target.selectionStart // kilocode_change + handleGhostTextInputChange(e) // kilocode_change - FIM autocomplete + + const newCursorPosition = target.selectionStart // Use target for consistency setCursorPosition(newCursorPosition) // kilocode_change start: pull slash commands from Cline @@ -846,7 +868,14 @@ export const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], + [ + setInputValue, + setSearchRequestId, + setFileSearchResults, + setSearchLoading, + resetOnInputChange, + handleGhostTextInputChange, // kilocode_change: FIM autocomplete + ], ) useEffect(() => { @@ -999,11 +1028,15 @@ export const ChatTextArea = forwardRef( } } // kilocode_change end + if (ghostText) { + processedText += `${escapeHtml(ghostText)}` + } + // kilocode_change end: FIM autocomplete ghost text display highlightLayerRef.current.innerHTML = processedText highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft - }, [customModes]) + }, [customModes, ghostText]) useLayoutEffect(() => { updateHighlights() diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx new file mode 100644 index 00000000000..81298e76e18 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx @@ -0,0 +1,456 @@ +import { renderHook, act, waitFor } from "@testing-library/react" +import { vi } from "vitest" +import { useChatGhostText } from "../useChatGhostText" + +// Mock vscode +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +describe("useChatGhostText", () => { + let mockTextArea: HTMLTextAreaElement + let textAreaRef: React.RefObject + + beforeEach(() => { + mockTextArea = document.createElement("textarea") + mockTextArea.value = "Hello world" + document.body.appendChild(mockTextArea) + textAreaRef = { current: mockTextArea } + document.execCommand = vi.fn(() => true) + }) + + afterEach(() => { + document.body.removeChild(mockTextArea) + vi.clearAllMocks() + }) + + describe("Tab key acceptance", () => { + it("should accept full ghost text on Tab key", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Simulate receiving ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " completion text", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + // Wait for ghost text to be set + waitFor(() => { + expect(result.current.ghostText).toBe(" completion text") + }) + + // Simulate Tab key press + const tabEvent = { + key: "Tab", + shiftKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + act(() => { + result.current.handleKeyDown(tabEvent) + }) + + expect(tabEvent.preventDefault).toHaveBeenCalled() + expect(document.execCommand).toHaveBeenCalledWith("insertText", false, " completion text") + expect(result.current.ghostText).toBe("") + }) + }) + + describe("Right Arrow key - word-by-word acceptance", () => { + it("should accept next word when cursor is at end", () => { + mockTextArea.value = "Hello world" + mockTextArea.selectionStart = 11 // At end + mockTextArea.selectionEnd = 11 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text manually for test + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " this is more text", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + // Simulate Right Arrow key press + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + act(() => { + result.current.handleKeyDown(arrowEvent) + }) + + expect(arrowEvent.preventDefault).toHaveBeenCalled() + expect(document.execCommand).toHaveBeenCalledWith("insertText", false, " this ") + expect(result.current.ghostText).toBe("is more text") + }) + + it("should handle multiple word acceptances", () => { + mockTextArea.value = "Start" + mockTextArea.selectionStart = 5 + mockTextArea.selectionEnd = 5 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " word1 word2 word3", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + // First word + act(() => { + result.current.handleKeyDown(arrowEvent) + }) + expect(result.current.ghostText).toBe("word2 word3") + + // Second word + act(() => { + result.current.handleKeyDown(arrowEvent) + }) + expect(result.current.ghostText).toBe("word3") + + // Third word + act(() => { + result.current.handleKeyDown(arrowEvent) + }) + expect(result.current.ghostText).toBe("") + }) + + it("should NOT accept word when cursor is not at end", () => { + mockTextArea.value = "Hello world" + mockTextArea.selectionStart = 5 // In middle + mockTextArea.selectionEnd = 5 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " more text", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + const handled = result.current.handleKeyDown(arrowEvent) + + expect(handled).toBe(false) + expect(arrowEvent.preventDefault).not.toHaveBeenCalled() + expect(result.current.ghostText).toBe(" more text") // Ghost text unchanged + }) + + it("should NOT accept word with Shift modifier", () => { + mockTextArea.value = "Test" + mockTextArea.selectionStart = 4 + mockTextArea.selectionEnd = 4 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " text", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: true, // Shift is pressed + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + const handled = result.current.handleKeyDown(arrowEvent) + + expect(handled).toBe(false) + expect(arrowEvent.preventDefault).not.toHaveBeenCalled() + }) + + it("should NOT accept word with Ctrl modifier", () => { + mockTextArea.value = "Test" + mockTextArea.selectionStart = 4 + mockTextArea.selectionEnd = 4 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " text", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: true, // Ctrl is pressed + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + const handled = result.current.handleKeyDown(arrowEvent) + + expect(handled).toBe(false) + expect(arrowEvent.preventDefault).not.toHaveBeenCalled() + }) + }) + + describe("Escape key", () => { + it("should clear ghost text on Escape", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " world", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + expect(result.current.ghostText).toBe(" world") + + // Simulate Escape key + const escapeEvent = { + key: "Escape", + } as React.KeyboardEvent + + act(() => { + result.current.handleKeyDown(escapeEvent) + }) + + expect(result.current.ghostText).toBe("") + }) + }) + + describe("Edge cases", () => { + it("should handle ghost text with only whitespace", () => { + mockTextArea.value = "Test" + mockTextArea.selectionStart = 4 + mockTextArea.selectionEnd = 4 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text with only whitespace + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " ", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + act(() => { + result.current.handleKeyDown(arrowEvent) + }) + + expect(document.execCommand).toHaveBeenCalledWith("insertText", false, " ") + expect(result.current.ghostText).toBe("") + }) + + it("should handle single word ghost text", () => { + mockTextArea.value = "Test" + mockTextArea.selectionStart = 4 + mockTextArea.selectionEnd = 4 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set single word ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: "word", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + act(() => { + result.current.handleKeyDown(arrowEvent) + }) + + expect(document.execCommand).toHaveBeenCalledWith("insertText", false, "word") + expect(result.current.ghostText).toBe("") + }) + + it("should handle empty ghost text gracefully", () => { + mockTextArea.value = "Test" + mockTextArea.selectionStart = 4 + mockTextArea.selectionEnd = 4 + + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + const arrowEvent = { + key: "ArrowRight", + shiftKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + const handled = result.current.handleKeyDown(arrowEvent) + + expect(handled).toBe(false) + expect(arrowEvent.preventDefault).not.toHaveBeenCalled() + }) + }) + + describe("clearGhostText", () => { + it("should clear ghost text when called", () => { + const { result } = renderHook(() => + useChatGhostText({ + textAreaRef, + enableChatAutocomplete: true, + }), + ) + + // Set ghost text + act(() => { + const messageEvent = new MessageEvent("message", { + data: { + type: "chatCompletionResult", + text: " completion", + requestId: "", + }, + }) + window.dispatchEvent(messageEvent) + }) + + expect(result.current.ghostText).toBe(" completion") + + act(() => { + result.current.clearGhostText() + }) + + expect(result.current.ghostText).toBe("") + }) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useChatGhostText.ts b/webview-ui/src/components/chat/hooks/useChatGhostText.ts new file mode 100644 index 00000000000..520b4594bee --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useChatGhostText.ts @@ -0,0 +1,174 @@ +// kilocode_change - new file +import { useCallback, useEffect, useRef, useState } from "react" +import { ExtensionMessage } from "@roo/ExtensionMessage" +import { vscode } from "@/utils/vscode" +import { generateRequestId } from "@roo/id" + +interface UseChatGhostTextOptions { + textAreaRef: React.RefObject + enableChatAutocomplete?: boolean +} + +interface UseChatGhostTextReturn { + ghostText: string + handleKeyDown: (event: React.KeyboardEvent) => boolean // Returns true if event was handled + handleInputChange: (e: React.ChangeEvent) => void + clearGhostText: () => void +} + +/** + * Hook for managing FIM autocomplete ghost text in the chat text area. + * Handles completion requests, ghost text display, and Tab/Escape/ArrowRight interactions. + */ +export function useChatGhostText({ + textAreaRef, + enableChatAutocomplete = true, +}: UseChatGhostTextOptions): UseChatGhostTextReturn { + const [ghostText, setGhostText] = useState("") + const completionDebounceRef = useRef(null) + const completionRequestIdRef = useRef("") + const skipNextCompletionRef = useRef(false) // Skip completion after accepting suggestion + + // Handle chat completion result messages + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + const message = event.data + if (message.type === "chatCompletionResult") { + // Only update if this is the response to our latest request + if (message.requestId === completionRequestIdRef.current) { + setGhostText(message.text || "") + } + } + } + + window.addEventListener("message", messageHandler) + return () => window.removeEventListener("message", messageHandler) + }, []) + + const clearGhostText = useCallback(() => { + setGhostText("") + }, []) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + const textArea = textAreaRef.current + if (!textArea) { + return false + } + + const hasSelection = textArea.selectionStart !== textArea.selectionEnd + const isCursorAtEnd = textArea.selectionStart === textArea.value.length + const canAcceptCompletion = ghostText && !hasSelection && isCursorAtEnd + + // Tab: Accept full ghost text + if (event.key === "Tab" && !event.shiftKey && canAcceptCompletion) { + event.preventDefault() + skipNextCompletionRef.current = true + insertTextAtCursor(textArea, ghostText) + setGhostText("") + return true + } + + // ArrowRight: Accept next word only + if ( + event.key === "ArrowRight" && + !event.shiftKey && + !event.ctrlKey && + !event.metaKey && + canAcceptCompletion + ) { + event.preventDefault() + skipNextCompletionRef.current = true + const { word, remainder } = extractNextWord(ghostText) + insertTextAtCursor(textArea, word) + setGhostText(remainder) + return true + } + + // Escape: Clear ghost text + if (event.key === "Escape" && ghostText) { + setGhostText("") + } + return false + }, + [ghostText, textAreaRef], + ) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value + + // Clear any existing ghost text when typing + setGhostText("") + + // Clear any pending completion request + if (completionDebounceRef.current) { + clearTimeout(completionDebounceRef.current) + } + + // Skip completion request if we just accepted a suggestion (Tab) or undid + if (skipNextCompletionRef.current) { + skipNextCompletionRef.current = false + // Don't request a new completion - wait for user to type more + } else if ( + enableChatAutocomplete && + newValue.length >= 5 && + !newValue.startsWith("/") && + !newValue.includes("@") + ) { + // Request new completion after debounce (only if feature is enabled) + completionDebounceRef.current = setTimeout(() => { + const requestId = generateRequestId() + completionRequestIdRef.current = requestId + vscode.postMessage({ + type: "requestChatCompletion", + text: newValue, + requestId, + }) + }, 300) // 300ms debounce + } + }, + [enableChatAutocomplete], + ) + + useEffect(() => { + return () => { + if (completionDebounceRef.current) { + clearTimeout(completionDebounceRef.current) + } + } + }, []) + + return { + ghostText, + handleKeyDown, + handleInputChange, + clearGhostText, + } +} + +/** + * Extracts the first word from ghost text, including surrounding whitespace. + * Mimics VS Code's word acceptance behavior: accepts leading space + word + trailing space as a unit. + * Returns the word and the remaining text. + */ +function extractNextWord(text: string): { word: string; remainder: string } { + if (!text) { + return { word: "", remainder: "" } + } + + // Match: optional leading whitespace + non-whitespace characters + optional trailing whitespace + // This captures " word " or "word " or " word" as complete units + const match = text.match(/^(\s*\S+\s*)/) + if (match) { + return { word: match[1], remainder: text.slice(match[1].length) } + } + + // If text is only whitespace, return all of it + return { word: text, remainder: "" } +} + +function insertTextAtCursor(textArea: HTMLTextAreaElement, text: string): void { + textArea.setSelectionRange(textArea.value.length, textArea.value.length) + document?.execCommand("insertText", false, text) +} diff --git a/webview-ui/src/components/kilocode/auth/AuthView.tsx b/webview-ui/src/components/kilocode/auth/AuthView.tsx new file mode 100644 index 00000000000..12ce064a1c1 --- /dev/null +++ b/webview-ui/src/components/kilocode/auth/AuthView.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react" +import { vscode } from "@/utils/vscode" +import { Tab, TabContent } from "../../common/Tab" +import DeviceAuthCard from "../common/DeviceAuthCard" + +interface AuthViewProps { + returnTo?: "chat" | "settings" + profileName?: string +} + +type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled" + +const AuthView: React.FC = ({ returnTo = "chat", profileName }) => { + const [deviceAuthStatus, setDeviceAuthStatus] = useState("idle") + const [deviceAuthCode, setDeviceAuthCode] = useState() + const [deviceAuthVerificationUrl, setDeviceAuthVerificationUrl] = useState() + const [deviceAuthExpiresIn, setDeviceAuthExpiresIn] = useState() + const [deviceAuthTimeRemaining, setDeviceAuthTimeRemaining] = useState() + const [deviceAuthError, setDeviceAuthError] = useState() + const [deviceAuthUserEmail, setDeviceAuthUserEmail] = useState() + + // Listen for device auth messages from extension + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case "deviceAuthStarted": + setDeviceAuthStatus("pending") + setDeviceAuthCode(message.deviceAuthCode) + setDeviceAuthVerificationUrl(message.deviceAuthVerificationUrl) + setDeviceAuthExpiresIn(message.deviceAuthExpiresIn) + setDeviceAuthTimeRemaining(message.deviceAuthExpiresIn) + setDeviceAuthError(undefined) + break + case "deviceAuthPolling": + setDeviceAuthTimeRemaining(message.deviceAuthTimeRemaining) + break + case "deviceAuthComplete": + console.log("[AuthView] Device auth complete received", { + profileName, + token: message.deviceAuthToken ? "present" : "missing", + userEmail: message.deviceAuthUserEmail, + }) + setDeviceAuthStatus("success") + setDeviceAuthUserEmail(message.deviceAuthUserEmail) + + // Always send profile-specific message to prevent double-save + // If no profileName, backend will use current profile + console.log( + "[AuthView] Sending deviceAuthCompleteWithProfile to profile:", + profileName || "current", + ) + vscode.postMessage({ + type: "deviceAuthCompleteWithProfile", + text: profileName || "", // Empty string means use current profile + values: { + token: message.deviceAuthToken, + userEmail: message.deviceAuthUserEmail, + }, + }) + + // Navigate back after 2 seconds + setTimeout(() => { + vscode.postMessage({ + type: "switchTab", + tab: returnTo, + values: profileName ? { editingProfile: profileName } : undefined, + }) + }, 2000) + break + case "deviceAuthFailed": + setDeviceAuthStatus("error") + setDeviceAuthError(message.deviceAuthError) + break + case "deviceAuthCancelled": + // Navigate back immediately on cancel + vscode.postMessage({ + type: "switchTab", + tab: returnTo, + values: profileName ? { editingProfile: profileName } : undefined, + }) + break + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [returnTo, profileName]) + + // Auto-start device auth when component mounts + useEffect(() => { + setDeviceAuthStatus("initiating") + vscode.postMessage({ type: "startDeviceAuth" }) + }, []) + + const handleCancelDeviceAuth = () => { + // Navigation will be handled by deviceAuthCancelled message + } + + const handleRetryDeviceAuth = () => { + setDeviceAuthStatus("idle") + setDeviceAuthError(undefined) + // Automatically start again + setTimeout(() => { + setDeviceAuthStatus("initiating") + vscode.postMessage({ type: "startDeviceAuth" }) + }, 100) + } + + return ( + + + + + + ) +} + +export default AuthView diff --git a/webview-ui/src/components/kilocode/common/DeviceAuthCard.tsx b/webview-ui/src/components/kilocode/common/DeviceAuthCard.tsx new file mode 100644 index 00000000000..4d2d502ac92 --- /dev/null +++ b/webview-ui/src/components/kilocode/common/DeviceAuthCard.tsx @@ -0,0 +1,281 @@ +import React, { useEffect, useState } from "react" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { generateQRCode } from "@/utils/kilocode/qrcode" +import { ButtonPrimary } from "./ButtonPrimary" +import { ButtonSecondary } from "./ButtonSecondary" +import { vscode } from "@/utils/vscode" +import Logo from "./Logo" + +interface DeviceAuthCardProps { + code?: string + verificationUrl?: string + expiresIn?: number + timeRemaining?: number + status: "idle" | "initiating" | "pending" | "success" | "error" | "cancelled" + error?: string + userEmail?: string + onCancel?: () => void + onRetry?: () => void +} + +// Inner component for initiating state +const InitiatingState: React.FC = () => { + const { t } = useAppTranslation() + return ( +
+ +
+ + {t("kilocode:deviceAuth.initiating")} +
+
+ ) +} + +// Inner component for success state +interface SuccessStateProps { + userEmail?: string +} + +const SuccessState: React.FC = ({ userEmail }) => { + const { t } = useAppTranslation() + return ( +
+ +

{t("kilocode:deviceAuth.success")}

+ {userEmail && ( +

+ {t("kilocode:deviceAuth.authenticatedAs", { email: userEmail })} +

+ )} +
+ ) +} + +// Inner component for error state +interface ErrorStateProps { + error?: string + onRetry: () => void +} + +const ErrorState: React.FC = ({ error, onRetry }) => { + const { t } = useAppTranslation() + return ( +
+ +

{t("kilocode:deviceAuth.error")}

+

+ {error || t("kilocode:deviceAuth.unknownError")} +

+ {t("kilocode:deviceAuth.retry")} +
+ ) +} + +// Inner component for cancelled state +interface CancelledStateProps { + onRetry: () => void +} + +const CancelledState: React.FC = ({ onRetry }) => { + const { t } = useAppTranslation() + return ( +
+ +

{t("kilocode:deviceAuth.cancelled")}

+ {t("kilocode:deviceAuth.tryAgain")} +
+ ) +} + +// Inner component for pending state +interface PendingStateProps { + code: string + verificationUrl: string + qrCodeDataUrl: string + timeRemaining?: number + formatTime: (seconds?: number) => string + onOpenBrowser: () => void + onCancel: () => void +} + +const PendingState: React.FC = ({ + code, + verificationUrl, + qrCodeDataUrl, + timeRemaining, + formatTime, + onOpenBrowser, + onCancel, +}) => { + const { t } = useAppTranslation() + const handleCopyUrl = () => { + navigator.clipboard.writeText(verificationUrl) + } + + return ( +
+

+ {t("kilocode:deviceAuth.title")} +

+ + {/* Step 1: URL Section */} +
+

+ {t("kilocode:deviceAuth.step1")} +

+ + {/* URL Box with Copy and Open Browser */} +
+
+ + {verificationUrl} + + +
+ {t("kilocode:deviceAuth.openBrowser")} +
+ + {/* QR Code Section */} + {qrCodeDataUrl && ( +
+

{t("kilocode:deviceAuth.scanQr")}

+ QR Code +
+ )} +
+ + {/* Step 2: Verification Section */} +
+

+ {t("kilocode:deviceAuth.step2")} +

+ + {/* Verification Code */} +
+
+ + {code} + +
+
+ + {/* Time Remaining */} +
+ + + {t("kilocode:deviceAuth.timeRemaining", { time: formatTime(timeRemaining) })} + +
+ + {/* Status */} +
+ + + {t("kilocode:deviceAuth.waiting")} + +
+
+ + {/* Cancel Button */} +
+ {t("kilocode:deviceAuth.cancel")} +
+
+ ) +} + +const DeviceAuthCard: React.FC = ({ + code, + verificationUrl, + timeRemaining, + status, + error, + userEmail, + onCancel, + onRetry, +}) => { + const [qrCodeDataUrl, setQrCodeDataUrl] = useState("") + + // Generate QR code when verification URL is available + useEffect(() => { + if (verificationUrl) { + generateQRCode(verificationUrl, { + width: 200, + margin: 2, + }) + .then(setQrCodeDataUrl) + .catch((err) => { + console.error("Failed to generate QR code:", err) + }) + } + }, [verificationUrl]) + + // Format time remaining as MM:SS + const formatTime = (seconds?: number): string => { + if (seconds === undefined) return "--:--" + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, "0")}` + } + + const handleOpenBrowser = () => { + if (verificationUrl) { + vscode.postMessage({ type: "openExternal", url: verificationUrl }) + } + } + + const handleCancel = () => { + vscode.postMessage({ type: "cancelDeviceAuth" }) + onCancel?.() + } + const handleRetry = () => { + onRetry?.() + } + + // Render different states + if (status === "initiating") { + return + } + + if (status === "success") { + return + } + + if (status === "error") { + return + } + + if (status === "cancelled") { + return + } + + // Pending state - show code and QR + if (status === "pending" && code && verificationUrl) { + return ( + + ) + } + + // Idle state - shouldn't normally be shown + return null +} + +export default DeviceAuthCard diff --git a/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx b/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx index 09868473b66..c52e1012c84 100644 --- a/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx +++ b/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx @@ -1,21 +1,131 @@ -import React from "react" -import { ButtonLink } from "./ButtonLink" +import React, { useEffect, useState } from "react" import { ButtonSecondary } from "./ButtonSecondary" +import { ButtonPrimary } from "./ButtonPrimary" import Logo from "./Logo" import { useAppTranslation } from "@/i18n/TranslationContext" -import { getKiloCodeBackendSignUpUrl } from "../helpers" -import { useExtensionState } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" +import DeviceAuthCard from "./DeviceAuthCard" interface KiloCodeAuthProps { onManualConfigClick?: () => void + onLoginClick?: () => void className?: string } -const KiloCodeAuth: React.FC = ({ onManualConfigClick, className = "" }) => { - const { uriScheme, uiKind, kiloCodeWrapperProperties } = useExtensionState() +type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled" +const KiloCodeAuth: React.FC = ({ onManualConfigClick, onLoginClick, className = "" }) => { const { t } = useAppTranslation() + const [deviceAuthStatus, setDeviceAuthStatus] = useState("idle") + const [deviceAuthCode, setDeviceAuthCode] = useState() + const [deviceAuthVerificationUrl, setDeviceAuthVerificationUrl] = useState() + const [deviceAuthExpiresIn, setDeviceAuthExpiresIn] = useState() + const [deviceAuthTimeRemaining, setDeviceAuthTimeRemaining] = useState() + const [deviceAuthError, setDeviceAuthError] = useState() + const [deviceAuthUserEmail, setDeviceAuthUserEmail] = useState() + // Listen for device auth messages from extension + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case "deviceAuthStarted": + setDeviceAuthStatus("pending") + setDeviceAuthCode(message.deviceAuthCode) + setDeviceAuthVerificationUrl(message.deviceAuthVerificationUrl) + setDeviceAuthExpiresIn(message.deviceAuthExpiresIn) + setDeviceAuthTimeRemaining(message.deviceAuthExpiresIn) + setDeviceAuthError(undefined) + break + case "deviceAuthPolling": + setDeviceAuthTimeRemaining(message.deviceAuthTimeRemaining) + break + case "deviceAuthComplete": + setDeviceAuthStatus("success") + setDeviceAuthUserEmail(message.deviceAuthUserEmail) + + // Save token to current profile + vscode.postMessage({ + type: "deviceAuthCompleteWithProfile", + text: "", // Empty string means use current profile + values: { + token: message.deviceAuthToken, + userEmail: message.deviceAuthUserEmail, + }, + }) + + // Navigate to chat tab after 2 seconds + setTimeout(() => { + vscode.postMessage({ + type: "switchTab", + tab: "chat", + }) + }, 2000) + break + case "deviceAuthFailed": + setDeviceAuthStatus("error") + setDeviceAuthError(message.deviceAuthError) + break + case "deviceAuthCancelled": + setDeviceAuthStatus("idle") + setDeviceAuthCode(undefined) + setDeviceAuthVerificationUrl(undefined) + setDeviceAuthExpiresIn(undefined) + setDeviceAuthTimeRemaining(undefined) + setDeviceAuthError(undefined) + break + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + const handleStartDeviceAuth = () => { + if (onLoginClick) { + onLoginClick() + } else { + setDeviceAuthStatus("initiating") + vscode.postMessage({ type: "startDeviceAuth" }) + } + } + + const handleCancelDeviceAuth = () => { + setDeviceAuthStatus("idle") + setDeviceAuthCode(undefined) + setDeviceAuthVerificationUrl(undefined) + setDeviceAuthExpiresIn(undefined) + setDeviceAuthTimeRemaining(undefined) + setDeviceAuthError(undefined) + } + + const handleRetryDeviceAuth = () => { + setDeviceAuthStatus("idle") + setDeviceAuthError(undefined) + // Automatically start again + setTimeout(() => handleStartDeviceAuth(), 100) + } + + // Show device auth card if auth is in progress + if (deviceAuthStatus !== "idle") { + return ( +
+ +
+ ) + } + + // Default welcome screen return (
@@ -26,15 +136,7 @@ const KiloCodeAuth: React.FC = ({ onManualConfigClick, classN

{t("kilocode:welcome.introText3")}

- { - if (uiKind === "Web" && onManualConfigClick) { - onManualConfigClick() - } - }}> - {t("kilocode:welcome.ctaButton")} - + {t("kilocode:welcome.ctaButton")} {!!onManualConfigClick && ( onManualConfigClick && onManualConfigClick()}> diff --git a/webview-ui/src/components/kilocode/settings/GhostServiceSettings.tsx b/webview-ui/src/components/kilocode/settings/GhostServiceSettings.tsx index 58caa769b3a..e12a0af7f1c 100644 --- a/webview-ui/src/components/kilocode/settings/GhostServiceSettings.tsx +++ b/webview-ui/src/components/kilocode/settings/GhostServiceSettings.tsx @@ -10,6 +10,7 @@ import { GhostServiceSettings, MODEL_SELECTION_ENABLED } from "@roo-code/types" import { vscode } from "@/utils/vscode" import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" import { useKeybindings } from "@/hooks/useKeybindings" +import { useExtensionState } from "../../../context/ExtensionStateContext" type GhostServiceSettingsViewProps = HTMLAttributes & { ghostServiceSettings: GhostServiceSettings @@ -26,8 +27,15 @@ export const GhostServiceSettingsView = ({ ...props }: GhostServiceSettingsViewProps) => { const { t } = useAppTranslation() - const { enableAutoTrigger, enableQuickInlineTaskKeybinding, enableSmartInlineTaskKeybinding, provider, model } = - ghostServiceSettings || {} + const { kiloCodeWrapperProperties } = useExtensionState() + const { + enableAutoTrigger, + enableQuickInlineTaskKeybinding, + enableSmartInlineTaskKeybinding, + enableChatAutocomplete, + provider, + model, + } = ghostServiceSettings || {} const keybindings = useKeybindings(["kilo-code.addToContextAndFocus", "kilo-code.ghost.generateSuggestions"]) const onEnableAutoTriggerChange = useCallback( @@ -51,6 +59,13 @@ export const GhostServiceSettingsView = ({ [onGhostServiceSettingsChange], ) + const onEnableChatAutocompleteChange = useCallback( + (e: any) => { + onGhostServiceSettingsChange("enableChatAutocomplete", e.target.checked) + }, + [onGhostServiceSettingsChange], + ) + const openGlobalKeybindings = (filter?: string) => { vscode.postMessage({ type: "openGlobalKeybindings", text: filter }) } @@ -82,53 +97,74 @@ export const GhostServiceSettingsView = ({
+ {!kiloCodeWrapperProperties?.kiloCodeWrapped && ( + <> +
+ + + {t("kilocode:ghost.settings.enableQuickInlineTaskKeybinding.label", { + keybinding: keybindings["kilo-code.addToContextAndFocus"], + })} + + +
+ + openGlobalKeybindings("kilo-code.addToContextAndFocus") + } + className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"> + ), + }} + /> +
+
+
+ + + {t("kilocode:ghost.settings.enableSmartInlineTaskKeybinding.label", { + keybinding: keybindings["kilo-code.ghost.generateSuggestions"], + })} + + +
+ + openGlobalKeybindings("kilo-code.ghost.generateSuggestions") + } + className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"> + ), + }} + /> +
+
+ + )} +
- - {t("kilocode:ghost.settings.enableQuickInlineTaskKeybinding.label", { - keybinding: keybindings["kilo-code.addToContextAndFocus"], - })} - - -
- openGlobalKeybindings("kilo-code.addToContextAndFocus")} - className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"> - ), - }} - /> -
-
-
- + checked={enableChatAutocomplete || false} + onChange={onEnableChatAutocompleteChange}> - {t("kilocode:ghost.settings.enableSmartInlineTaskKeybinding.label", { - keybinding: keybindings["kilo-code.ghost.generateSuggestions"], - })} + {t("kilocode:ghost.settings.enableChatAutocomplete.label")}
- openGlobalKeybindings("kilo-code.ghost.generateSuggestions")} - className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"> - ), - }} - /> +
diff --git a/webview-ui/src/components/kilocode/settings/__tests__/GhostServiceSettings.spec.tsx b/webview-ui/src/components/kilocode/settings/__tests__/GhostServiceSettings.spec.tsx index 334cb27c641..946f6d86c91 100644 --- a/webview-ui/src/components/kilocode/settings/__tests__/GhostServiceSettings.spec.tsx +++ b/webview-ui/src/components/kilocode/settings/__tests__/GhostServiceSettings.spec.tsx @@ -49,6 +49,13 @@ vi.mock("@/hooks/useKeybindings", () => ({ }), })) +// Mock useExtensionState hook +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + kiloCodeWrapperProperties: undefined, + }), +})) + // Mock VSCodeCheckbox to render as regular HTML checkbox for testing vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeCheckbox: ({ checked, onChange, children }: any) => ( @@ -88,6 +95,7 @@ const defaultGhostServiceSettings: GhostServiceSettings = { enableAutoTrigger: false, enableQuickInlineTaskKeybinding: false, enableSmartInlineTaskKeybinding: false, + enableChatAutocomplete: false, provider: "openrouter", model: "openai/gpt-4o-mini", } @@ -170,6 +178,18 @@ describe("GhostServiceSettingsView", () => { expect(onGhostServiceSettingsChange).toHaveBeenCalledWith("enableSmartInlineTaskKeybinding", true) }) + it("toggles chat autocomplete checkbox correctly", () => { + const onGhostServiceSettingsChange = vi.fn() + renderComponent({ onGhostServiceSettingsChange }) + + const checkboxLabel = screen.getByText(/kilocode:ghost.settings.enableChatAutocomplete.label/).closest("label") + const checkbox = checkboxLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement + + fireEvent.click(checkbox) + + expect(onGhostServiceSettingsChange).toHaveBeenCalledWith("enableChatAutocomplete", true) + }) + it("renders Trans components with proper structure", () => { renderComponent() diff --git a/webview-ui/src/components/kilocode/settings/providers/KiloCode.tsx b/webview-ui/src/components/kilocode/settings/providers/KiloCode.tsx index d38e1619b3b..e8f444df83a 100644 --- a/webview-ui/src/components/kilocode/settings/providers/KiloCode.tsx +++ b/webview-ui/src/components/kilocode/settings/providers/KiloCode.tsx @@ -1,16 +1,13 @@ import { useCallback } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { getKiloCodeBackendSignInUrl } from "../../helpers" import { Button } from "@src/components/ui" import { type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" import type { RouterModels } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { inputEventTransform } from "../../../settings/transforms" import { ModelPicker } from "../../../settings/ModelPicker" import { vscode } from "@src/utils/vscode" import { OrganizationSelector } from "../../common/OrganizationSelector" -import { KiloCodeWrapperProperties } from "../../../../../../src/shared/kilocode/wrapper" import { getAppUrl } from "@roo-code/types" import { useKiloIdentity } from "@src/utils/kilocode/useKiloIdentity" @@ -21,9 +18,6 @@ type KiloCodeProps = { hideKiloCodeButton?: boolean routerModels?: RouterModels organizationAllowList: OrganizationAllowList - uriScheme: string | undefined - kiloCodeWrapperProperties: KiloCodeWrapperProperties | undefined - uiKind: string | undefined kilocodeDefaultModel: string } @@ -34,9 +28,6 @@ export const KiloCode = ({ hideKiloCodeButton, routerModels, organizationAllowList, - uriScheme, - uiKind, - kiloCodeWrapperProperties, kilocodeDefaultModel, }: KiloCodeProps) => { const { t } = useAppTranslation() @@ -92,11 +83,17 @@ export const KiloCode = ({ ) : ( - + onClick={() => { + vscode.postMessage({ + type: "switchTab", + tab: "auth", + values: { returnTo: "settings", profileName: currentApiConfigName }, + }) + }}> {t("kilocode:settings.provider.login")} - + ))} { const { t } = useAppTranslation() - const { - organizationAllowList, - uiKind, // kilocode_change - kiloCodeWrapperProperties, // kilocode_change - kilocodeDefaultModel, - cloudIsAuthenticated, - } = useExtensionState() + const { organizationAllowList, kilocodeDefaultModel, cloudIsAuthenticated } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -575,9 +569,6 @@ const ApiOptions = ({ currentApiConfigName={currentApiConfigName} routerModels={routerModels} organizationAllowList={organizationAllowList} - uriScheme={uriScheme} - uiKind={uiKind} - kiloCodeWrapperProperties={kiloCodeWrapperProperties} kilocodeDefaultModel={kilocodeDefaultModel} /> )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index dd5deb2493b..76573456a98 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -120,19 +120,17 @@ type SectionName = (typeof sectionNames)[number] // kilocode_change type SettingsViewProps = { onDone: () => void targetSection?: string + editingProfile?: string // kilocode_change - profile to edit } -const SettingsView = forwardRef(({ onDone, targetSection }, ref) => { +// kilocode_change start - editingProfile +const SettingsView = forwardRef((props, ref) => { + const { onDone, targetSection, editingProfile } = props + // kilocode_change end - editingProfile const { t } = useAppTranslation() const extensionState = useExtensionState() - const { - currentApiConfigName, - listApiConfigMeta, - uriScheme, - kiloCodeWrapperProperties, // kilocode_change - settingsImportedAt, - } = extensionState + const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) @@ -270,8 +268,26 @@ const SettingsView = forwardRef(({ onDone, t setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName setChangeDetected(false) - setEditingApiConfigName(currentApiConfigName || "default") // kilocode_change: Sync editing profile when active profile changes - }, [currentApiConfigName, extensionState]) + // kilocode_change start - Don't reset editingApiConfigName if we have an editingProfile prop (from auth return) + if (!editingProfile) { + setEditingApiConfigName(currentApiConfigName || "default") + } + // kilocode_change end + }, [currentApiConfigName, extensionState, editingProfile]) // kilocode_change + + // kilocode_change start: Set editing profile when prop changes (from auth return) + useEffect(() => { + if (editingProfile) { + console.log("[SettingsView] Setting editing profile from prop:", editingProfile) + setEditingApiConfigName(editingProfile) + isLoadingProfileForEditing.current = true + vscode.postMessage({ + type: "getProfileConfigurationForEditing", + text: editingProfile, + }) + } + }, [editingProfile]) + // kilocode_change end // kilocode_change start const isLoadingProfileForEditing = useRef(false) @@ -721,7 +737,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "browser", icon: SquareMousePointer }, { id: "checkpoints", icon: GitBranch }, { id: "display", icon: Monitor }, // kilocode_change - ...(kiloCodeWrapperProperties?.kiloCodeWrapped ? [] : [{ id: "ghost" as const, icon: Bot }]), // kilocode_change + { id: "ghost" as const, icon: Bot }, // kilocode_change { id: "notifications", icon: Bell }, { id: "contextManagement", icon: Database }, { id: "terminal", icon: SquareTerminal }, @@ -732,7 +748,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "mcp", icon: Server }, { id: "about", icon: Info }, ], - [kiloCodeWrapperProperties?.kiloCodeWrapped], // kilocode_change + [], // kilocode_change ) // Update target section logic to set active tab useEffect(() => { @@ -741,6 +757,32 @@ const SettingsView = forwardRef(({ onDone, t } }, [targetSection]) // kilocode_change + // kilocode_change start - Listen for messages to restore editing profile after auth + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if ( + message.type === "action" && + message.action === "settingsButtonClicked" && + message.values?.editingProfile + ) { + const profileToEdit = message.values.editingProfile as string + console.log("[SettingsView] Restoring editing profile:", profileToEdit) + setEditingApiConfigName(profileToEdit) + // Request the profile's configuration for editing + isLoadingProfileForEditing.current = true + vscode.postMessage({ + type: "getProfileConfigurationForEditing", + text: profileToEdit, + }) + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + // kilocode_change end + // Function to scroll the active tab into view for vertical layout const scrollToActiveTab = useCallback(() => { const activeTabElement = tabRefs.current[activeTab] @@ -951,14 +993,16 @@ const SettingsView = forwardRef(({ onDone, t /> {/* kilocode_change end changes to allow for editting a non-active profile */} + {/* kilocode_change start - pass editing profile name */} + {/* kilocode_change end - pass editing profile name */} )} diff --git a/webview-ui/src/i18n/locales/ar/kilocode.json b/webview-ui/src/i18n/locales/ar/kilocode.json index aedbda964ac..d7147b49e26 100644 --- a/webview-ui/src/i18n/locales/ar/kilocode.json +++ b/webview-ui/src/i18n/locales/ar/kilocode.json @@ -240,7 +240,11 @@ "noModelConfigured": "لم يتم العثور على نموذج إكمال تلقائي مناسب. يرجى تكوين مزود في إعدادات API.", "configureAutocompleteProfile": "استخدم أي نموذج بالانتقال إلى الملفات الشخصية وتكوين ملف شخصي من نوع الإكمال التلقائي.", "model": "النموذج", - "provider": "المزود" + "provider": "المزود", + "enableChatAutocomplete": { + "label": "الإكمال التلقائي لمدخلات الدردشة", + "description": "عند التفعيل، سيقترح Kilo Code إكمالات أثناء كتابتك في مدخل الدردشة. اضغط على Tab لقبول الاقتراحات." + } } }, "virtualProvider": { @@ -287,5 +291,24 @@ }, "modes": { "shareModesNewBanner": "جديد: مشاركة الأوضاع عن طريق إنشاء منظمة" + }, + "deviceAuth": { + "title": "تسجيل الدخول إلى Kilo Code", + "step1": "افتح الرابط التالي في متصفحك", + "step2": "تحقق من الرمز وصرّح لهذا الجهاز في متصفحك", + "scanQr": "أو امسح رمز QR هذا بهاتفك", + "openBrowser": "فتح المتصفح", + "copyUrl": "نسخ الرابط", + "waiting": "في انتظار التصريح...", + "timeRemaining": "ينتهي الرمز خلال {{time}}", + "success": "تم تسجيل الدخول بنجاح!", + "authenticatedAs": "تم المصادقة كـ {{email}}", + "error": "خطأ في المصادقة", + "unknownError": "حدث خطأ. يرجى المحاولة مرة أخرى.", + "cancel": "إلغاء", + "retry": "حاول مرة أخرى", + "tryAgain": "حاول مرة أخرى", + "cancelled": "تم إلغاء المصادقة", + "initiating": "جاري بدء المصادقة..." } } diff --git a/webview-ui/src/i18n/locales/ca/kilocode.json b/webview-ui/src/i18n/locales/ca/kilocode.json index 6230fc8fd91..42aa86c213f 100644 --- a/webview-ui/src/i18n/locales/ca/kilocode.json +++ b/webview-ui/src/i18n/locales/ca/kilocode.json @@ -231,7 +231,11 @@ "noModelConfigured": "No s'ha trobat cap model d'autocompletat adequat. Configura un proveïdor a la configuració de l'API.", "configureAutocompleteProfile": "Utilitza qualsevol model anant a perfils i configurant un Perfil del Tipus de Perfil Autocompletat.", "model": "Model", - "provider": "Proveïdor" + "provider": "Proveïdor", + "enableChatAutocomplete": { + "description": "Quan està activat, Kilo Code suggerirà complecions mentre escriviu a l'entrada del xat. Premeu Tab per acceptar els suggeriments.", + "label": "Autocompletat d'entrada de xat" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Nou: Comparteix modes creant una organització" + }, + "deviceAuth": { + "title": "Inicia sessió a Kilo Code", + "step1": "Obre la següent URL al teu navegador", + "step2": "Verifica el codi i autoritza aquest dispositiu al teu navegador", + "scanQr": "O escaneja aquest codi QR amb el teu telèfon", + "openBrowser": "Obre el navegador", + "copyUrl": "Copia l'URL", + "waiting": "Esperant autorització...", + "timeRemaining": "El codi caduca en {{time}}", + "success": "Sessió iniciada correctament!", + "authenticatedAs": "Autenticat com a {{email}}", + "error": "Error d'autenticació", + "unknownError": "S'ha produït un error. Torna-ho a provar.", + "cancel": "Cancel·la", + "retry": "Torna-ho a provar", + "tryAgain": "Torna-ho a provar", + "cancelled": "Autenticació cancel·lada", + "initiating": "Iniciant autenticació..." } } diff --git a/webview-ui/src/i18n/locales/cs/kilocode.json b/webview-ui/src/i18n/locales/cs/kilocode.json index ef39d082eaf..f4956b678e0 100644 --- a/webview-ui/src/i18n/locales/cs/kilocode.json +++ b/webview-ui/src/i18n/locales/cs/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "Nebyl nalezen žádný vhodný model pro automatické dokončování. Nakonfiguruj prosím poskytovatele v nastavení API.", "configureAutocompleteProfile": "Použij libovolný model tak, že přejdeš do profilů a nakonfiguruješ Profil typu Automatické dokončování.", "model": "Model", - "provider": "Poskytovatel" + "provider": "Poskytovatel", + "enableChatAutocomplete": { + "label": "Automatické doplňování při psaní zpráv", + "description": "Když je tato funkce povolena, Kilo Code bude navrhovat dokončování textu během psaní ve vstupním poli chatu. Stisknutím klávesy Tab přijmete návrhy." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Novinka: Sdílejte režimy vytvořením organizace" + }, + "deviceAuth": { + "title": "Přihlásit se do Kilo Code", + "step1": "Otevři následující URL ve svém prohlížeči", + "step2": "Ověř kód a autorizuj toto zařízení ve svém prohlížeči", + "scanQr": "Nebo naskenuj tento QR kód svým telefonem", + "openBrowser": "Otevřít prohlížeč", + "copyUrl": "Kopírovat URL", + "waiting": "Čekání na autorizaci...", + "timeRemaining": "Kód vyprší za {{time}}", + "success": "Úspěšně přihlášen!", + "authenticatedAs": "Ověřen jako {{email}}", + "error": "Chyba ověření", + "unknownError": "Došlo k chybě. Zkus to prosím znovu.", + "cancel": "Zrušit", + "retry": "Zkusit znovu", + "tryAgain": "Zkusit znovu", + "cancelled": "Ověření zrušeno", + "initiating": "Spouštění ověření..." } } diff --git a/webview-ui/src/i18n/locales/de/kilocode.json b/webview-ui/src/i18n/locales/de/kilocode.json index 224bd86d5cd..aa3261c28f7 100644 --- a/webview-ui/src/i18n/locales/de/kilocode.json +++ b/webview-ui/src/i18n/locales/de/kilocode.json @@ -237,7 +237,11 @@ }, "keybindingNotFound": "nicht gefunden", "noModelConfigured": "Kein geeignetes Autocomplete-Modell gefunden. Bitte konfiguriere einen Provider in den API-Einstellungen.", - "configureAutocompleteProfile": "Verwende ein beliebiges Modell, indem du zu Profilen gehst und ein Profil vom Profiltyp Autocomplete konfigurierst." + "configureAutocompleteProfile": "Verwende ein beliebiges Modell, indem du zu Profilen gehst und ein Profil vom Profiltyp Autocomplete konfigurierst.", + "enableChatAutocomplete": { + "label": "Chat-Eingabe-Autovervollständigung", + "description": "Wenn aktiviert, wird Kilo Code Vervollständigungen vorschlagen, während Sie im Chat-Eingabefeld tippen. Drücken Sie Tab, um Vorschläge zu akzeptieren." + } } }, "virtualProvider": { @@ -287,5 +291,24 @@ }, "modes": { "shareModesNewBanner": "Neu: Modi teilen durch Erstellen einer Organisation" + }, + "deviceAuth": { + "title": "Bei Kilo Code anmelden", + "step1": "Öffne die folgende URL in deinem Browser", + "step2": "Überprüfe den Code und autorisiere dieses Gerät in deinem Browser", + "scanQr": "Oder scanne diesen QR-Code mit deinem Handy", + "openBrowser": "Browser öffnen", + "copyUrl": "URL kopieren", + "waiting": "Warte auf Autorisierung...", + "timeRemaining": "Code läuft ab in {{time}}", + "success": "Erfolgreich angemeldet!", + "authenticatedAs": "Authentifiziert als {{email}}", + "error": "Authentifizierungsfehler", + "unknownError": "Ein Fehler ist aufgetreten. Bitte versuche es erneut.", + "cancel": "Abbrechen", + "retry": "Erneut versuchen", + "tryAgain": "Erneut versuchen", + "cancelled": "Authentifizierung abgebrochen", + "initiating": "Authentifizierung wird gestartet..." } } diff --git a/webview-ui/src/i18n/locales/en/kilocode.json b/webview-ui/src/i18n/locales/en/kilocode.json index e840fb66a90..21f85eaf338 100644 --- a/webview-ui/src/i18n/locales/en/kilocode.json +++ b/webview-ui/src/i18n/locales/en/kilocode.json @@ -249,6 +249,10 @@ "label": "Manual Autocomplete ({{keybinding}})", "description": "Need a quick fix, completion, or refactor? Kilo will use the surrounding context to offer immediate improvements, keeping you in the flow. Edit shortcut" }, + "enableChatAutocomplete": { + "label": "Chat Input Autocomplete", + "description": "When enabled, Kilo Code will suggest completions as you type in the chat input. Press Tab to accept suggestions." + }, "keybindingNotFound": "not found", "noModelConfigured": "No suitable autocomplete model found. Please configure a provider in the API settings.", "configureAutocompleteProfile": "Use any model by going to profiles and configuring a Profile of the Profile Type Autocomplete." @@ -286,5 +290,24 @@ }, "modes": { "shareModesNewBanner": "New: Share modes by creating an organization" + }, + "deviceAuth": { + "title": "Sign in to Kilo Code", + "step1": "Open the following URL on your browser", + "step2": "Verify the code and authorize this device on your browser", + "scanQr": "Or scan this QR code with your phone", + "openBrowser": "Open Browser", + "copyUrl": "Copy URL", + "waiting": "Waiting for authorization...", + "timeRemaining": "Code expires in {{time}}", + "success": "Successfully signed in!", + "authenticatedAs": "Authenticated as {{email}}", + "error": "Authentication Error", + "unknownError": "An error occurred. Please try again.", + "cancel": "Cancel", + "retry": "Try Again", + "tryAgain": "Try Again", + "cancelled": "Authentication Cancelled", + "initiating": "Starting authentication..." } } diff --git a/webview-ui/src/i18n/locales/es/kilocode.json b/webview-ui/src/i18n/locales/es/kilocode.json index 102763c7d26..3ef0b498525 100644 --- a/webview-ui/src/i18n/locales/es/kilocode.json +++ b/webview-ui/src/i18n/locales/es/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "No se encontró ningún modelo de autocompletado adecuado. Por favor, configura un proveedor en la configuración de API.", "configureAutocompleteProfile": "Usa cualquier modelo yendo a perfiles y configurando un Perfil del Tipo de Perfil Autocompletado.", "model": "Modelo", - "provider": "Proveedor" + "provider": "Proveedor", + "enableChatAutocomplete": { + "description": "Cuando está habilitado, Kilo Code sugerirá completaciones mientras escribes en el campo de chat. Presiona Tab para aceptar las sugerencias.", + "label": "Autocompletado de Entrada de Chat" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Nuevo: Comparte modos creando una organización" + }, + "deviceAuth": { + "title": "Iniciar sesión en Kilo Code", + "step1": "Abre la siguiente URL en tu navegador", + "step2": "Verifica el código y autoriza este dispositivo en tu navegador", + "scanQr": "O escanea este código QR con tu teléfono", + "openBrowser": "Abrir navegador", + "copyUrl": "Copiar URL", + "waiting": "Esperando autorización...", + "timeRemaining": "El código expira en {{time}}", + "success": "¡Sesión iniciada correctamente!", + "authenticatedAs": "Autenticado como {{email}}", + "error": "Error de autenticación", + "unknownError": "Ocurrió un error. Por favor, inténtalo de nuevo.", + "cancel": "Cancelar", + "retry": "Intentar de nuevo", + "tryAgain": "Intentar de nuevo", + "cancelled": "Autenticación cancelada", + "initiating": "Iniciando autenticación..." } } diff --git a/webview-ui/src/i18n/locales/fr/kilocode.json b/webview-ui/src/i18n/locales/fr/kilocode.json index dc4a97272e8..813b4becd3f 100644 --- a/webview-ui/src/i18n/locales/fr/kilocode.json +++ b/webview-ui/src/i18n/locales/fr/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "Aucun modèle d'autocomplétion approprié trouvé. Configure un fournisseur dans les paramètres API.", "configureAutocompleteProfile": "Utilise n'importe quel modèle en allant dans les profils et en configurant un Profil du Type de Profil Autocomplétion.", "model": "Modèle", - "provider": "Fournisseur" + "provider": "Fournisseur", + "enableChatAutocomplete": { + "label": "Saisie automatique de discussion", + "description": "Lorsqu'il est activé, Kilo Code proposera des suggestions de complétion pendant que vous tapez dans le champ de discussion. Appuyez sur Tab pour accepter les suggestions." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Nouveau : Partagez des modes en créant une organisation" + }, + "deviceAuth": { + "title": "Se connecter à Kilo Code", + "step1": "Ouvre l'URL suivante dans ton navigateur", + "step2": "Vérifie le code et autorise cet appareil dans ton navigateur", + "scanQr": "Ou scanne ce code QR avec ton téléphone", + "openBrowser": "Ouvrir le navigateur", + "copyUrl": "Copier l'URL", + "waiting": "En attente d'autorisation...", + "timeRemaining": "Le code expire dans {{time}}", + "success": "Connexion réussie !", + "authenticatedAs": "Authentifié en tant que {{email}}", + "error": "Erreur d'authentification", + "unknownError": "Une erreur s'est produite. Réessaye.", + "cancel": "Annuler", + "retry": "Réessayer", + "tryAgain": "Réessayer", + "cancelled": "Authentification annulée", + "initiating": "Démarrage de l'authentification..." } } diff --git a/webview-ui/src/i18n/locales/hi/kilocode.json b/webview-ui/src/i18n/locales/hi/kilocode.json index f7e1bf27624..be7f045f456 100644 --- a/webview-ui/src/i18n/locales/hi/kilocode.json +++ b/webview-ui/src/i18n/locales/hi/kilocode.json @@ -231,7 +231,11 @@ "noModelConfigured": "कोई उपयुक्त ऑटोकम्पलीट मॉडल नहीं मिला। कृपया API सेटिंग्स में एक प्रदाता कॉन्फ़िगर करें।", "configureAutocompleteProfile": "प्रोफाइल में जाकर और ऑटोकम्पलीट प्रोफाइल टाइप की एक प्रोफाइल कॉन्फ़िगर करके किसी भी मॉडल का उपयोग करें।", "model": "मॉडल", - "provider": "प्रदाता" + "provider": "प्रदाता", + "enableChatAutocomplete": { + "label": "चैट इनपुट स्वतःपूर्ण", + "description": "जब सक्षम होता है, Kilo Code चैट इनपुट में टाइप करते समय पूर्णताएं सुझाएगा। सुझावों को स्वीकार करने के लिए Tab दबाएं।" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "नया: एक संगठन बनाकर मोड साझा करें" + }, + "deviceAuth": { + "title": "Kilo Code में साइन इन करें", + "step1": "अपने ब्राउज़र में निम्नलिखित URL खोलें", + "step2": "कोड सत्यापित करें और अपने ब्राउज़र में इस डिवाइस को अधिकृत करें", + "scanQr": "या अपने फोन से इस QR कोड को स्कैन करें", + "openBrowser": "ब्राउज़र खोलें", + "copyUrl": "URL कॉपी करें", + "waiting": "प्राधिकरण की प्रतीक्षा में...", + "timeRemaining": "कोड {{time}} में समाप्त हो जाएगा", + "success": "सफलतापूर्वक साइन इन हो गया!", + "authenticatedAs": "{{email}} के रूप में प्रमाणित", + "error": "प्रमाणीकरण त्रुटि", + "unknownError": "एक त्रुटि हुई। कृपया पुनः प्रयास करें।", + "cancel": "रद्द करें", + "retry": "पुनः प्रयास करें", + "tryAgain": "पुनः प्रयास करें", + "cancelled": "प्रमाणीकरण रद्द किया गया", + "initiating": "प्रमाणीकरण शुरू हो रहा है..." } } diff --git a/webview-ui/src/i18n/locales/id/kilocode.json b/webview-ui/src/i18n/locales/id/kilocode.json index 9034c7cc4ec..a8e32b83651 100644 --- a/webview-ui/src/i18n/locales/id/kilocode.json +++ b/webview-ui/src/i18n/locales/id/kilocode.json @@ -231,7 +231,11 @@ "noModelConfigured": "Tidak ditemukan model autocomplete yang sesuai. Silakan konfigurasi penyedia di pengaturan API.", "configureAutocompleteProfile": "Gunakan model apa pun dengan pergi ke profil dan mengonfigurasi Profil dengan Tipe Profil Autocomplete.", "model": "Model", - "provider": "Penyedia" + "provider": "Penyedia", + "enableChatAutocomplete": { + "description": "Jika diaktifkan, Kilo Code akan menyarankan pelengkapan saat Anda mengetik di kotak obrolan. Tekan Tab untuk menerima saran.", + "label": "Pelengkapan Otomatis Input Obrolan" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Baru: Bagikan mode dengan membuat organisasi" + }, + "deviceAuth": { + "title": "Masuk ke Kilo Code", + "step1": "Buka URL berikut di browser kamu", + "step2": "Verifikasi kode dan otorisasi perangkat ini di browser kamu", + "scanQr": "Atau pindai kode QR ini dengan ponsel kamu", + "openBrowser": "Buka Browser", + "copyUrl": "Salin URL", + "waiting": "Menunggu otorisasi...", + "timeRemaining": "Kode kedaluwarsa dalam {{time}}", + "success": "Berhasil masuk!", + "authenticatedAs": "Diautentikasi sebagai {{email}}", + "error": "Kesalahan Autentikasi", + "unknownError": "Terjadi kesalahan. Silakan coba lagi.", + "cancel": "Batal", + "retry": "Coba Lagi", + "tryAgain": "Coba Lagi", + "cancelled": "Autentikasi Dibatalkan", + "initiating": "Memulai autentikasi..." } } diff --git a/webview-ui/src/i18n/locales/it/kilocode.json b/webview-ui/src/i18n/locales/it/kilocode.json index c1bde89280a..bf6600e617b 100644 --- a/webview-ui/src/i18n/locales/it/kilocode.json +++ b/webview-ui/src/i18n/locales/it/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "Nessun modello di autocompletamento adatto trovato. Configura un provider nelle impostazioni API.", "configureAutocompleteProfile": "Usa qualsiasi modello andando nei profili e configurando un Profilo del Tipo di Profilo Autocompletamento.", "model": "Modello", - "provider": "Provider" + "provider": "Provider", + "enableChatAutocomplete": { + "label": "Completamento Automatico dell'Input della Chat", + "description": "Quando abilitato, Kilo Code suggerirà completamenti mentre digiti nell'area di chat. Premi Tab per accettare i suggerimenti." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Novità: Condividi le modalità creando un'organizzazione" + }, + "deviceAuth": { + "title": "Accedi a Kilo Code", + "step1": "Apri il seguente URL nel tuo browser", + "step2": "Verifica il codice e autorizza questo dispositivo nel tuo browser", + "scanQr": "Oppure scansiona questo codice QR con il tuo telefono", + "openBrowser": "Apri Browser", + "copyUrl": "Copia URL", + "waiting": "In attesa di autorizzazione...", + "timeRemaining": "Il codice scade tra {{time}}", + "success": "Accesso effettuato con successo!", + "authenticatedAs": "Autenticato come {{email}}", + "error": "Errore di autenticazione", + "unknownError": "Si è verificato un errore. Riprova.", + "cancel": "Annulla", + "retry": "Riprova", + "tryAgain": "Riprova", + "cancelled": "Autenticazione annullata", + "initiating": "Avvio autenticazione..." } } diff --git a/webview-ui/src/i18n/locales/ja/kilocode.json b/webview-ui/src/i18n/locales/ja/kilocode.json index 4c639729a3d..3193b3abc3f 100644 --- a/webview-ui/src/i18n/locales/ja/kilocode.json +++ b/webview-ui/src/i18n/locales/ja/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "適切なオートコンプリートモデルが見つかりませんでした。API設定でプロバイダーを設定してください。", "configureAutocompleteProfile": "プロファイルに移動し、プロファイルタイプがオートコンプリートのプロファイルを設定することで、任意のモデルを使用できます。", "model": "モデル", - "provider": "プロバイダー" + "provider": "プロバイダー", + "enableChatAutocomplete": { + "description": "有効にすると、Kilo Codeはチャット入力の入力中に候補を提案します。Tabキーを押して候補を受け入れることができます。", + "label": "チャット入力オートコンプリート" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "新機能: 組織を作成してモードを共有" + }, + "deviceAuth": { + "title": "Kilo Code にサインイン", + "step1": "ブラウザで次のURLを開いてください", + "step2": "コードを確認し、ブラウザでこのデバイスを承認してください", + "scanQr": "またはスマートフォンでこのQRコードをスキャンしてください", + "openBrowser": "ブラウザを開く", + "copyUrl": "URLをコピー", + "waiting": "承認を待っています...", + "timeRemaining": "コードは {{time}} で期限切れになります", + "success": "サインインに成功しました!", + "authenticatedAs": "{{email}} として認証されました", + "error": "認証エラー", + "unknownError": "エラーが発生しました。もう一度お試しください。", + "cancel": "キャンセル", + "retry": "再試行", + "tryAgain": "再試行", + "cancelled": "認証がキャンセルされました", + "initiating": "認証を開始しています..." } } diff --git a/webview-ui/src/i18n/locales/ko/kilocode.json b/webview-ui/src/i18n/locales/ko/kilocode.json index 5b5ad72fb01..26daff0b4c7 100644 --- a/webview-ui/src/i18n/locales/ko/kilocode.json +++ b/webview-ui/src/i18n/locales/ko/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "적합한 자동완성 모델을 찾을 수 없습니다. API 설정에서 제공자를 구성하세요.", "configureAutocompleteProfile": "프로필로 이동하여 프로필 유형이 자동완성인 프로필을 구성하면 모든 모델을 사용할 수 있습니다.", "model": "모델", - "provider": "제공자" + "provider": "제공자", + "enableChatAutocomplete": { + "label": "채팅 입력 자동완성", + "description": "활성화되면, Kilo Code는 채팅 입력 시 입력하는 대로 완성 제안을 제공합니다. Tab 키를 눌러 제안을 수락하세요." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "신규: 조직을 생성하여 모드 공유하기" + }, + "deviceAuth": { + "title": "Kilo Code에 로그인", + "step1": "브라우저에서 다음 URL을 여세요", + "step2": "코드를 확인하고 브라우저에서 이 기기를 승인하세요", + "scanQr": "또는 휴대폰으로 이 QR 코드를 스캔하세요", + "openBrowser": "브라우저 열기", + "copyUrl": "URL 복사", + "waiting": "승인 대기 중...", + "timeRemaining": "코드가 {{time}} 후에 만료됩니다", + "success": "로그인에 성공했습니다!", + "authenticatedAs": "{{email}}(으)로 인증됨", + "error": "인증 오류", + "unknownError": "오류가 발생했습니다. 다시 시도해 주세요.", + "cancel": "취소", + "retry": "다시 시도", + "tryAgain": "다시 시도", + "cancelled": "인증이 취소되었습니다", + "initiating": "인증을 시작하는 중..." } } diff --git a/webview-ui/src/i18n/locales/nl/kilocode.json b/webview-ui/src/i18n/locales/nl/kilocode.json index f8d9bc161bf..1c014ade6c5 100644 --- a/webview-ui/src/i18n/locales/nl/kilocode.json +++ b/webview-ui/src/i18n/locales/nl/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "Geen geschikt autocomplete-model gevonden. Configureer een provider in de API-instellingen.", "configureAutocompleteProfile": "Gebruik elk model door naar profielen te gaan en een Profiel van het Profieltype Autocomplete te configureren.", "model": "Model", - "provider": "Provider" + "provider": "Provider", + "enableChatAutocomplete": { + "label": "Chatinvoer Automatisch Aanvullen", + "description": "Wanneer ingeschakeld, zal Kilo Code suggesties voor aanvullingen geven terwijl je typt in de chatinvoer. Druk op Tab om suggesties te accepteren." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Nieuw: Deel modi door een organisatie aan te maken" + }, + "deviceAuth": { + "title": "Inloggen bij Kilo Code", + "step1": "Open de volgende URL in je browser", + "step2": "Verifieer de code en autoriseer dit apparaat in je browser", + "scanQr": "Of scan deze QR-code met je telefoon", + "openBrowser": "Browser openen", + "copyUrl": "URL kopiëren", + "waiting": "Wachten op autorisatie...", + "timeRemaining": "Code verloopt over {{time}}", + "success": "Succesvol ingelogd!", + "authenticatedAs": "Geauthenticeerd als {{email}}", + "error": "Authenticatiefout", + "unknownError": "Er is een fout opgetreden. Probeer het opnieuw.", + "cancel": "Annuleren", + "retry": "Opnieuw proberen", + "tryAgain": "Opnieuw proberen", + "cancelled": "Authenticatie geannuleerd", + "initiating": "Authenticatie starten..." } } diff --git a/webview-ui/src/i18n/locales/pl/kilocode.json b/webview-ui/src/i18n/locales/pl/kilocode.json index 6b21b418cfd..b78f64d7a63 100644 --- a/webview-ui/src/i18n/locales/pl/kilocode.json +++ b/webview-ui/src/i18n/locales/pl/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "Nie znaleziono odpowiedniego modelu autouzupełniania. Skonfiguruj dostawcę w ustawieniach API.", "configureAutocompleteProfile": "Użyj dowolnego modelu, przechodząc do profili i konfigurując Profil typu Autouzupełnianie.", "model": "Model", - "provider": "Dostawca" + "provider": "Dostawca", + "enableChatAutocomplete": { + "label": "Automatyczne uzupełnianie wpisów czatu", + "description": "Po włączeniu, Kilo Code będzie sugerować uzupełnienia podczas pisania w oknie czatu. Naciśnij Tab, aby zaakceptować sugestie." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Nowość: Udostępniaj tryby tworząc organizację" + }, + "deviceAuth": { + "title": "Zaloguj się do Kilo Code", + "step1": "Otwórz następujący adres URL w przeglądarce", + "step2": "Zweryfikuj kod i autoryzuj to urządzenie w przeglądarce", + "scanQr": "Lub zeskanuj ten kod QR telefonem", + "openBrowser": "Otwórz przeglądarkę", + "copyUrl": "Kopiuj URL", + "waiting": "Oczekiwanie na autoryzację...", + "timeRemaining": "Kod wygasa za {{time}}", + "success": "Pomyślnie zalogowano!", + "authenticatedAs": "Uwierzytelniono jako {{email}}", + "error": "Błąd uwierzytelniania", + "unknownError": "Wystąpił błąd. Spróbuj ponownie.", + "cancel": "Anuluj", + "retry": "Spróbuj ponownie", + "tryAgain": "Spróbuj ponownie", + "cancelled": "Uwierzytelnianie anulowane", + "initiating": "Rozpoczynanie uwierzytelniania..." } } diff --git a/webview-ui/src/i18n/locales/pt-BR/kilocode.json b/webview-ui/src/i18n/locales/pt-BR/kilocode.json index 92f9e870856..22a0833c3fb 100644 --- a/webview-ui/src/i18n/locales/pt-BR/kilocode.json +++ b/webview-ui/src/i18n/locales/pt-BR/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "Nenhum modelo de autocompletar adequado encontrado. Configure um provedor nas configurações da API.", "configureAutocompleteProfile": "Use qualquer modelo indo para perfis e configurando um Perfil do Tipo de Perfil Autocompletar.", "model": "Modelo", - "provider": "Provedor" + "provider": "Provedor", + "enableChatAutocomplete": { + "label": "Preenchimento Automático do Chat", + "description": "Quando ativado, o Kilo Code irá sugerir conclusões enquanto você digita no campo de chat. Pressione Tab para aceitar as sugestões." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Novo: Compartilhe modos criando uma organização" + }, + "deviceAuth": { + "title": "Entrar no Kilo Code", + "step1": "Abra a seguinte URL no seu navegador", + "step2": "Verifique o código e autorize este dispositivo no seu navegador", + "scanQr": "Ou escaneie este código QR com seu telefone", + "openBrowser": "Abrir navegador", + "copyUrl": "Copiar URL", + "waiting": "Aguardando autorização...", + "timeRemaining": "O código expira em {{time}}", + "success": "Login realizado com sucesso!", + "authenticatedAs": "Autenticado como {{email}}", + "error": "Erro de autenticação", + "unknownError": "Ocorreu um erro. Por favor, tente novamente.", + "cancel": "Cancelar", + "retry": "Tentar novamente", + "tryAgain": "Tentar novamente", + "cancelled": "Autenticação cancelada", + "initiating": "Iniciando autenticação..." } } diff --git a/webview-ui/src/i18n/locales/ru/kilocode.json b/webview-ui/src/i18n/locales/ru/kilocode.json index ff7a8aeb732..d9324b66c4a 100644 --- a/webview-ui/src/i18n/locales/ru/kilocode.json +++ b/webview-ui/src/i18n/locales/ru/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "Подходящая модель автодополнения не найдена. Настрой провайдера в настройках API.", "configureAutocompleteProfile": "Используй любую модель, перейдя в профили и настроив Профиль типа Автодополнение.", "model": "Модель", - "provider": "Провайдер" + "provider": "Провайдер", + "enableChatAutocomplete": { + "label": "Автодополнение в чате", + "description": "При включении Kilo Code будет предлагать варианты завершения во время ввода в окне чата. Нажмите Tab, чтобы принять предложения." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Новое: Делитесь режимами путем создания организации" + }, + "deviceAuth": { + "title": "Войти в Kilo Code", + "step1": "Открой следующий URL в своем браузере", + "step2": "Проверь код и авторизуй это устройство в своем браузере", + "scanQr": "Или отсканируй этот QR-код своим телефоном", + "openBrowser": "Открыть браузер", + "copyUrl": "Скопировать URL", + "waiting": "Ожидание авторизации...", + "timeRemaining": "Код истекает через {{time}}", + "success": "Успешный вход!", + "authenticatedAs": "Аутентифицирован как {{email}}", + "error": "Ошибка аутентификации", + "unknownError": "Произошла ошибка. Попробуй снова.", + "cancel": "Отмена", + "retry": "Попробовать снова", + "tryAgain": "Попробовать снова", + "cancelled": "Аутентификация отменена", + "initiating": "Запуск аутентификации..." } } diff --git a/webview-ui/src/i18n/locales/th/kilocode.json b/webview-ui/src/i18n/locales/th/kilocode.json index 6571dffc2f2..1a2db287452 100644 --- a/webview-ui/src/i18n/locales/th/kilocode.json +++ b/webview-ui/src/i18n/locales/th/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "ไม่พบโมเดลเติมข้อความอัตโนมัติที่เหมาะสม กรุณาตั้งค่าผู้ให้บริการในการตั้งค่า API", "configureAutocompleteProfile": "ใช้โมเดลใดก็ได้โดยไปที่โปรไฟล์และตั้งค่าโปรไฟล์ประเภทเติมข้อความอัตโนมัติ", "model": "โมเดล", - "provider": "ผู้ให้บริการ" + "provider": "ผู้ให้บริการ", + "enableChatAutocomplete": { + "label": "การเติมข้อความอัตโนมัติในการแชท", + "description": "เมื่อเปิดใช้งาน Kilo Code จะแนะนำข้อความเพื่อให้คุณพิมพ์ต่อในช่องแชท กดแท็บเพื่อยอมรับคำแนะนำ" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "ใหม่: แชร์โหมดโดยการสร้างองค์กร" + }, + "deviceAuth": { + "title": "ลงชื่อเข้าใช้ Kilo Code", + "step1": "เปิด URL ต่อไปนี้ในเบราว์เซอร์ของคุณ", + "step2": "ตรวจสอบรหัสและอนุญาตอุปกรณ์นี้ในเบราว์เซอร์ของคุณ", + "scanQr": "หรือสแกนรหัส QR นี้ด้วยโทรศัพท์ของคุณ", + "openBrowser": "เปิดเบราว์เซอร์", + "copyUrl": "คัดลอก URL", + "waiting": "กำลังรอการอนุญาต...", + "timeRemaining": "รหัสจะหมดอายุใน {{time}}", + "success": "ลงชื่อเข้าใช้สำเร็จ!", + "authenticatedAs": "ยืนยันตัวตนเป็น {{email}}", + "error": "ข้อผิดพลาดในการยืนยันตัวตน", + "unknownError": "เกิดข้อผิดพลาด โปรดลองอีกครั้ง", + "cancel": "ยกเลิก", + "retry": "ลองอีกครั้ง", + "tryAgain": "ลองอีกครั้ง", + "cancelled": "ยกเลิกการยืนยันตัวตนแล้ว", + "initiating": "กำลังเริ่มการยืนยันตัวตน..." } } diff --git a/webview-ui/src/i18n/locales/tr/kilocode.json b/webview-ui/src/i18n/locales/tr/kilocode.json index add412ca3a6..b102dae066e 100644 --- a/webview-ui/src/i18n/locales/tr/kilocode.json +++ b/webview-ui/src/i18n/locales/tr/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "Uygun otomatik tamamlama modeli bulunamadı. Lütfen API ayarlarında bir sağlayıcı yapılandır.", "configureAutocompleteProfile": "Profillere giderek ve Otomatik Tamamlama Profil Türünde bir Profil yapılandırarak herhangi bir model kullan.", "model": "Model", - "provider": "Sağlayıcı" + "provider": "Sağlayıcı", + "enableChatAutocomplete": { + "label": "Sohbet Girişi Otomatik Tamamlama", + "description": "Etkinleştirildiğinde, Kilo Code sohbet girişinde yazarken tamamlama önerileri sunacaktır. Önerileri kabul etmek için Tab tuşuna basın." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Yeni: Bir organizasyon oluşturarak modları paylaşın" + }, + "deviceAuth": { + "title": "Kilo Code'a giriş yap", + "step1": "Tarayıcında aşağıdaki URL'yi aç", + "step2": "Kodu doğrula ve bu cihazı tarayıcında yetkilendir", + "scanQr": "Veya bu QR kodunu telefonunla tara", + "openBrowser": "Tarayıcıyı Aç", + "copyUrl": "URL'yi Kopyala", + "waiting": "Yetkilendirme bekleniyor...", + "timeRemaining": "Kod {{time}} içinde sona erecek", + "success": "Başarıyla giriş yapıldı!", + "authenticatedAs": "{{email}} olarak kimlik doğrulandı", + "error": "Kimlik Doğrulama Hatası", + "unknownError": "Bir hata oluştu. Lütfen tekrar dene.", + "cancel": "İptal", + "retry": "Tekrar Dene", + "tryAgain": "Tekrar Dene", + "cancelled": "Kimlik Doğrulama İptal Edildi", + "initiating": "Kimlik doğrulama başlatılıyor..." } } diff --git a/webview-ui/src/i18n/locales/uk/kilocode.json b/webview-ui/src/i18n/locales/uk/kilocode.json index 98c3511d9f1..b50faf79cc5 100644 --- a/webview-ui/src/i18n/locales/uk/kilocode.json +++ b/webview-ui/src/i18n/locales/uk/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "Не знайдено відповідної моделі автодоповнення. Налаштуй провайдера в налаштуваннях API.", "configureAutocompleteProfile": "Використовуй будь-яку модель, перейшовши до профілів і налаштувавши Профіль типу Автодоповнення.", "model": "Модель", - "provider": "Провайдер" + "provider": "Провайдер", + "enableChatAutocomplete": { + "label": "Автозаповнення вводу чату", + "description": "Якщо ввімкнено, Kilo Code пропонуватиме доповнення під час введення в чаті. Натисніть Tab, щоб прийняти пропозиції." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Нове: Діліться режимами, створивши організацію" + }, + "deviceAuth": { + "title": "Увійти в Kilo Code", + "step1": "Відкрий наступний URL у своєму браузері", + "step2": "Перевір код і авторизуй цей пристрій у своєму браузері", + "scanQr": "Або відскануй цей QR-код своїм телефоном", + "openBrowser": "Відкрити браузер", + "copyUrl": "Скопіювати URL", + "waiting": "Очікування авторизації...", + "timeRemaining": "Код закінчується через {{time}}", + "success": "Успішний вхід!", + "authenticatedAs": "Автентифіковано як {{email}}", + "error": "Помилка автентифікації", + "unknownError": "Сталася помилка. Спробуй знову.", + "cancel": "Скасувати", + "retry": "Спробувати знову", + "tryAgain": "Спробувати знову", + "cancelled": "Автентифікацію скасовано", + "initiating": "Запуск автентифікації..." } } diff --git a/webview-ui/src/i18n/locales/vi/kilocode.json b/webview-ui/src/i18n/locales/vi/kilocode.json index 850f66f328d..963f2d8652c 100644 --- a/webview-ui/src/i18n/locales/vi/kilocode.json +++ b/webview-ui/src/i18n/locales/vi/kilocode.json @@ -238,7 +238,11 @@ "noModelConfigured": "Không tìm thấy mô hình tự động hoàn thành phù hợp. Vui lòng cấu hình nhà cung cấp trong cài đặt API.", "configureAutocompleteProfile": "Sử dụng bất kỳ mô hình nào bằng cách vào hồ sơ và cấu hình Hồ sơ có Loại Hồ sơ là Tự động hoàn thành.", "model": "Mô hình", - "provider": "Nhà cung cấp" + "provider": "Nhà cung cấp", + "enableChatAutocomplete": { + "label": "Gợi ý Tự động Hoàn thành Trong Trò chuyện", + "description": "Khi được bật, Kilo Code sẽ đề xuất các gợi ý hoàn thành khi bạn nhập trong ô chat. Nhấn Tab để chấp nhận các gợi ý." + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "Mới: Chia sẻ chế độ bằng cách tạo một tổ chức" + }, + "deviceAuth": { + "title": "Đăng nhập vào Kilo Code", + "step1": "Mở URL sau trên trình duyệt của bạn", + "step2": "Xác minh mã và ủy quyền thiết bị này trên trình duyệt của bạn", + "scanQr": "Hoặc quét mã QR này bằng điện thoại của bạn", + "openBrowser": "Mở trình duyệt", + "copyUrl": "Sao chép URL", + "waiting": "Đang chờ ủy quyền...", + "timeRemaining": "Mã hết hạn sau {{time}}", + "success": "Đăng nhập thành công!", + "authenticatedAs": "Đã xác thực với tư cách {{email}}", + "error": "Lỗi xác thực", + "unknownError": "Đã xảy ra lỗi. Vui lòng thử lại.", + "cancel": "Hủy", + "retry": "Thử lại", + "tryAgain": "Thử lại", + "cancelled": "Đã hủy xác thực", + "initiating": "Đang bắt đầu xác thực..." } } diff --git a/webview-ui/src/i18n/locales/zh-CN/kilocode.json b/webview-ui/src/i18n/locales/zh-CN/kilocode.json index 21ac26c6b40..827f5a91db7 100644 --- a/webview-ui/src/i18n/locales/zh-CN/kilocode.json +++ b/webview-ui/src/i18n/locales/zh-CN/kilocode.json @@ -245,7 +245,11 @@ "noModelConfigured": "未找到合适的自动补全模型。请在 API 设置中配置提供商。", "configureAutocompleteProfile": "前往配置文件并配置自动补全类型的配置文件即可使用任意模型。", "model": "模型", - "provider": "提供商" + "provider": "提供商", + "enableChatAutocomplete": { + "label": "聊天输入自动完成", + "description": "启用后,Kilo Code 将在您输入聊天内容时提供补全建议。按 Tab 键接受建议。" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "新功能:通过创建组织来共享模式" + }, + "deviceAuth": { + "title": "登录 Kilo Code", + "step1": "在浏览器中打开以下 URL", + "step2": "验证代码并在浏览器中授权此设备", + "scanQr": "或用手机扫描此二维码", + "openBrowser": "打开浏览器", + "copyUrl": "复制 URL", + "waiting": "等待授权...", + "timeRemaining": "代码将在 {{time}} 后过期", + "success": "登录成功!", + "authenticatedAs": "已验证为 {{email}}", + "error": "验证错误", + "unknownError": "发生错误。请重试。", + "cancel": "取消", + "retry": "重试", + "tryAgain": "重试", + "cancelled": "验证已取消", + "initiating": "正在启动验证..." } } diff --git a/webview-ui/src/i18n/locales/zh-TW/kilocode.json b/webview-ui/src/i18n/locales/zh-TW/kilocode.json index b6fc90fde1d..d648193de25 100644 --- a/webview-ui/src/i18n/locales/zh-TW/kilocode.json +++ b/webview-ui/src/i18n/locales/zh-TW/kilocode.json @@ -240,7 +240,11 @@ "noModelConfigured": "找不到合適的自動補全模型。請在 API 設定中配置提供者。", "configureAutocompleteProfile": "前往設定檔並設定自動補全類型的設定檔即可使用任意模型。", "model": "模型", - "provider": "供應商" + "provider": "供應商", + "enableChatAutocomplete": { + "label": "聊天输入自动完成", + "description": "启用后,Kilo Code 将在您在聊天输入框中输入时提供补全建议。按 Tab 键接受建议。" + } } }, "virtualProvider": { @@ -288,5 +292,24 @@ }, "modes": { "shareModesNewBanner": "新功能:通过创建组织共享模式" + }, + "deviceAuth": { + "title": "登入 Kilo Code", + "step1": "在瀏覽器中開啟以下 URL", + "step2": "驗證代碼並在瀏覽器中授權此裝置", + "scanQr": "或用手機掃描此 QR 碼", + "openBrowser": "開啟瀏覽器", + "copyUrl": "複製 URL", + "waiting": "等待授權...", + "timeRemaining": "代碼將在 {{time}} 後過期", + "success": "登入成功!", + "authenticatedAs": "已驗證為 {{email}}", + "error": "驗證錯誤", + "unknownError": "發生錯誤。請重試。", + "cancel": "取消", + "retry": "重試", + "tryAgain": "重試", + "cancelled": "驗證已取消", + "initiating": "正在啟動驗證..." } } diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index e0f74c9c5de..581001cda75 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -410,6 +410,15 @@ vscode-dropdown::part(listbox) { font-family: var(--font-mono); } +/* kilocode_change start: FIM autocomplete ghost text */ +.chat-ghost-text { + color: var(--vscode-editorGhostText-foreground, rgba(255, 255, 255, 0.4)); + opacity: 0.6; + pointer-events: none; + user-select: none; +} +/* kilocode_change end: FIM autocomplete ghost text */ + /** * vscrui Overrides / Hacks */ diff --git a/webview-ui/src/utils/highlight.ts b/webview-ui/src/utils/highlight.ts index 21c84f993a8..02117e22555 100644 --- a/webview-ui/src/utils/highlight.ts +++ b/webview-ui/src/utils/highlight.ts @@ -3,7 +3,7 @@ import { LRUCache } from "lru-cache" // LRU cache for escapeHtml with reasonable size limit const escapeHtmlCache = new LRUCache({ max: 500 }) -function escapeHtml(text: string): string { +export function escapeHtml(text: string): string { // Check cache first const cached = escapeHtmlCache.get(text) if (cached !== undefined) { diff --git a/webview-ui/src/utils/kilocode/qrcode.ts b/webview-ui/src/utils/kilocode/qrcode.ts new file mode 100644 index 00000000000..b4394b7590e --- /dev/null +++ b/webview-ui/src/utils/kilocode/qrcode.ts @@ -0,0 +1,41 @@ +import QRCode from "qrcode" + +interface QRCodeOptions { + width?: number + margin?: number + color?: { + dark?: string + light?: string + } +} + +const DEFAULT_OPTIONS = { + width: 200, + margin: 2, + color: { + dark: "#000000", + light: "#FFFFFF", + }, +} as const + +function buildQRCodeOptions(options?: QRCodeOptions) { + return { + width: options?.width ?? DEFAULT_OPTIONS.width, + margin: options?.margin ?? DEFAULT_OPTIONS.margin, + color: { + dark: options?.color?.dark ?? DEFAULT_OPTIONS.color.dark, + light: options?.color?.light ?? DEFAULT_OPTIONS.color.light, + }, + } +} + +export async function generateQRCode(text: string, options?: QRCodeOptions): Promise { + return QRCode.toDataURL(text, buildQRCodeOptions(options)) +} + +export async function generateQRCodeSVG(text: string, options?: QRCodeOptions): Promise { + return QRCode.toString(text, { + type: "svg", + ...buildQRCodeOptions(options), + }) +}