Browse Source

Merge branch 'main' into roo-v3.36.2

Kevin van Dijk 2 months ago
parent
commit
0bdc51c9d0
100 changed files with 7641 additions and 979 deletions
  1. 5 0
      .changeset/agent-manager-question-support.md
  2. 6 0
      .changeset/brave-frogs-check.md
  3. 0 5
      .changeset/cozy-terms-lay.md
  4. 0 5
      .changeset/empty-berries-warn.md
  5. 6 0
      .changeset/giant-spoons-jump.md
  6. 0 5
      .changeset/happy-mice-hug.md
  7. 5 0
      .changeset/loud-ads-slide.md
  8. 0 5
      .changeset/tidy-nights-raise.md
  9. 0 5
      .changeset/wide-mails-cry.md
  10. 38 0
      CHANGELOG.md
  11. 8 7
      README.md
  12. 87 0
      apps/kilocode-docs/docs/advanced-usage/agent-manager.md
  13. 8 7
      apps/kilocode-docs/docs/advanced-usage/cloud-agent.md
  14. 13 11
      apps/kilocode-docs/docs/advanced-usage/managed-indexing.md
  15. 58 0
      apps/kilocode-docs/docs/advanced-usage/sessions.md
  16. 6 0
      apps/kilocode-docs/docs/cli.md
  17. 2 0
      apps/kilocode-docs/sidebars.ts
  18. 6 0
      cli/CHANGELOG.md
  19. 16 2
      cli/README.md
  20. 1 1
      cli/package.dist.json
  21. 1 1
      cli/package.json
  22. 1 1
      cli/src/cli.ts
  23. 17 37
      cli/src/state/atoms/effects.ts
  24. 55 2
      jetbrains/host/src/rpcManager.ts
  25. 2 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadCommandsShape.kt
  26. 41 6
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadLanguageFeaturesShape.kt
  27. 13 20
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/actors/MainThreadWindowShape.kt
  28. 80 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/commands/SetContextCommands.kt
  29. 122 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ContextManager.kt
  30. 1 5
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/RPCManager.kt
  31. 0 4
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/core/ServiceProxyRegistry.kt
  32. 17 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt
  33. 233 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionManager.kt
  34. 251 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionService.kt
  35. 134 0
      jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt
  36. 1 0
      jetbrains/plugin/src/main/resources/META-INF/plugin.xml.template
  37. 1 0
      packages/types/src/index.ts
  38. 51 0
      packages/types/src/kilocode/device-auth.ts
  39. 1 0
      packages/types/src/kilocode/kilocode.ts
  40. 11 0
      packages/types/src/telemetry.ts
  41. 2 1
      src/api/providers/kilocode-openrouter.ts
  42. 46 3
      src/core/kilocode/agent-manager/AgentManagerProvider.ts
  43. 63 3
      src/core/kilocode/agent-manager/CliProcessHandler.ts
  44. 182 0
      src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts
  45. 148 0
      src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts
  46. 40 0
      src/core/kilocode/agent-manager/telemetry.ts
  47. 150 0
      src/core/kilocode/webview/deviceAuthHandler.ts
  48. 65 0
      src/core/kilocode/webview/webviewMessageHandlerUtils.ts
  49. 3 0
      src/core/kilocode/wrapper.ts
  50. 30 5
      src/core/webview/ClineProvider.ts
  51. 49 1
      src/core/webview/webviewMessageHandler.ts
  52. 10 7
      src/extension.ts
  53. 1 1
      src/package.json
  54. 34 42
      src/services/code-index/managed/ManagedIndexer.ts
  55. 5 97
      src/services/code-index/managed/__tests__/ManagedIndexer.spec.ts
  56. 29 0
      src/services/code-index/managed/api-client.ts
  57. 291 0
      src/services/ghost/GhostJetbrainsBridge.ts
  58. 8 3
      src/services/ghost/GhostServiceManager.ts
  59. 359 0
      src/services/ghost/__tests__/GhostJetbrainsBridge.spec.ts
  60. 194 0
      src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts
  61. 90 0
      src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts
  62. 37 0
      src/services/ghost/chat-autocomplete/handleChatCompletionRequest.ts
  63. 123 119
      src/services/ghost/classic-auto-complete/AutocompleteTelemetry.ts
  64. 34 13
      src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts
  65. 47 16
      src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts
  66. 154 0
      src/services/ghost/context/VisibleCodeTracker.ts
  67. 235 0
      src/services/ghost/context/__tests__/VisibleCodeTracker.spec.ts
  68. 4 0
      src/services/ghost/index.ts
  69. 86 0
      src/services/ghost/types.ts
  70. 195 0
      src/services/kilocode/DeviceAuthService.ts
  71. 302 0
      src/services/kilocode/__tests__/DeviceAuthService.test.ts
  72. 15 1
      src/shared/ExtensionMessage.ts
  73. 7 1
      src/shared/WebviewMessage.ts
  74. 7 0
      src/shared/id.ts
  75. 330 279
      src/shared/kilocode/cli-sessions/core/SessionManager.ts
  76. 206 38
      src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts
  77. 289 101
      src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts
  78. 5 0
      src/shared/kilocode/errorUtils.ts
  79. 1 0
      src/shared/kilocode/wrapper.ts
  80. 685 0
      src/test-llm-autocompletion/html-report.ts
  81. 2 1
      src/test-llm-autocompletion/package.json
  82. 30 13
      src/test-llm-autocompletion/runner.ts
  83. 48 4
      webview-ui/src/App.tsx
  84. 24 1
      webview-ui/src/components/chat/ChatRow.tsx
  85. 50 5
      webview-ui/src/components/chat/ChatTextArea.tsx
  86. 14 0
      webview-ui/src/components/chat/ErrorRow.tsx
  87. 142 0
      webview-ui/src/components/chat/__tests__/ChatRow.kilocode-auth-error.spec.tsx
  88. 122 0
      webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx
  89. 456 0
      webview-ui/src/components/chat/hooks/__tests__/useChatGhostText.spec.tsx
  90. 174 0
      webview-ui/src/components/chat/hooks/useChatGhostText.ts
  91. 129 0
      webview-ui/src/components/kilocode/auth/AuthView.tsx
  92. 281 0
      webview-ui/src/components/kilocode/common/DeviceAuthCard.tsx
  93. 117 15
      webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx
  94. 79 43
      webview-ui/src/components/kilocode/settings/GhostServiceSettings.tsx
  95. 20 0
      webview-ui/src/components/kilocode/settings/__tests__/GhostServiceSettings.spec.tsx
  96. 9 12
      webview-ui/src/components/kilocode/settings/providers/KiloCode.tsx
  97. 1 10
      webview-ui/src/components/settings/ApiOptions.tsx
  98. 57 13
      webview-ui/src/components/settings/SettingsView.tsx
  99. 3 1
      webview-ui/src/i18n/locales/ar/agentManager.json
  100. 24 1
      webview-ui/src/i18n/locales/ar/kilocode.json

+ 5 - 0
.changeset/agent-manager-question-support.md

@@ -0,0 +1,5 @@
+---
+"@roo-code/vscode-webview": patch
+---
+
+Add follow-up question answer buttons to Agent Manager, allowing users to click suggestion buttons to respond to agent questions

+ 6 - 0
.changeset/brave-frogs-check.md

@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": minor
+"kilo-code": minor
+---
+
+improve session sync mechanism (event based instead of timer)

+ 0 - 5
.changeset/cozy-terms-lay.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Add batch size and number of retries to the indexing options

+ 0 - 5
.changeset/empty-berries-warn.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-check token before syncing session

+ 6 - 0
.changeset/giant-spoons-jump.md

@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": patch
+"kilo-code": patch
+---
+
+extract an extension message handler for extension/cli reuse

+ 0 - 5
.changeset/happy-mice-hug.md

@@ -1,5 +0,0 @@
----
-"kilo-code": minor
----
-
-add session versioning

+ 5 - 0
.changeset/loud-ads-slide.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix Kilo Auth flow

+ 0 - 5
.changeset/tidy-nights-raise.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Fix: reset state errors when clearing indexing state

+ 0 - 5
.changeset/wide-mails-cry.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": minor
----
-
-Fix race during session restoration

+ 38 - 0
CHANGELOG.md

@@ -1,5 +1,43 @@
 # kilo-code
 
+## 4.134.0
+
+### Minor Changes
+
+- [#4330](https://github.com/Kilo-Org/kilocode/pull/4330) [`57dc5a9`](https://github.com/Kilo-Org/kilocode/commit/57dc5a9379b25eb2e1f9902486ff71db731a5aaf) Thanks [@catrielmuller](https://github.com/catrielmuller)! - JetBrains IDEs: Autocomplete is now available and can be enabled in Settings > Autocomplete.
+
+- [#4178](https://github.com/Kilo-Org/kilocode/pull/4178) [`414282a`](https://github.com/Kilo-Org/kilocode/commit/414282a5a5c6cdfe528c3a7775bf07cd3e0739aa) Thanks [@catrielmuller](https://github.com/catrielmuller)! - 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.
+
+- [#4334](https://github.com/Kilo-Org/kilocode/pull/4334) [`5bdab7c`](https://github.com/Kilo-Org/kilocode/commit/5bdab7caca867970a5ee7faccfb76e36e01c6471) Thanks [@brianc](https://github.com/brianc)! - Updated managed indexing gate logic to be able to roll it out to individuals instead of just organizations.
+
+- [#3999](https://github.com/Kilo-Org/kilocode/pull/3999) [`7f349d0`](https://github.com/Kilo-Org/kilocode/commit/7f349d04749f74a9b84de8cb68f44d8d8d71cbc5) Thanks [@hassoncs](https://github.com/hassoncs)! - Add Autocomplete support to the chat text box. It can be enabled/disabled using a new toggle in the autocomplete settings menu
+
+### Patch Changes
+
+- [#4327](https://github.com/Kilo-Org/kilocode/pull/4327) [`52fc352`](https://github.com/Kilo-Org/kilocode/commit/52fc3524151f30d3925408d30fd8af9265890b77) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - fix agent creation getting stuck when CLI doesn't respond with session_created event
+
+- [#4182](https://github.com/Kilo-Org/kilocode/pull/4182) [`33c9eab`](https://github.com/Kilo-Org/kilocode/commit/33c9eabd2ef395e585f37542980e996054bf3fcb) Thanks [@catrielmuller](https://github.com/catrielmuller)! - Jetbrains - Fix open external urls
+
+## 4.133.0
+
+### Minor Changes
+
+- [#4317](https://github.com/Kilo-Org/kilocode/pull/4317) [`797c959`](https://github.com/Kilo-Org/kilocode/commit/797c9594a527f19e0d39b7402fb031cd9eb4e2a7) Thanks [@iscekic](https://github.com/iscekic)! - add session versioning
+
+### Patch Changes
+
+- [#3571](https://github.com/Kilo-Org/kilocode/pull/3571) [`ea2702c`](https://github.com/Kilo-Org/kilocode/commit/ea2702c6f29e7ff2bfe55714716f72bb43cfbede) Thanks [@yadue](https://github.com/yadue)! - Add batch size and number of retries to the indexing options
+
+- [#4310](https://github.com/Kilo-Org/kilocode/pull/4310) [`e5e6085`](https://github.com/Kilo-Org/kilocode/commit/e5e6085d1f9b4f142130eddd3eaddb52bd5cde17) Thanks [@iscekic](https://github.com/iscekic)! - check token before syncing session
+
+- [#4272](https://github.com/Kilo-Org/kilocode/pull/4272) [`3ad35d9`](https://github.com/Kilo-Org/kilocode/commit/3ad35d94a5560ca1b87b2b393c6d064703c144d4) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Fix: reset state errors when clearing indexing state
+
 ## 4.132.0
 
 ### Minor Changes

+ 8 - 7
README.md

@@ -6,9 +6,10 @@
   <a href="https://www.reddit.com/r/kilocode/"><img src="https://img.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
 </p>
 
-# 🚀 Kilo Code
+# 🚀 Kilo
 
-> Kilo is an open-source AI coding agent.
+> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
+> #1 on OpenRouter. 750k+ Kilo Coders. 6.1 trillion tokens used per month.
 
 - ✨ Generate code from natural language
 - ✅ Checks its own work
@@ -16,7 +17,7 @@
 - 🌐 Automate the browser
 - 🤖 Latest AI models
 - 🎁 API keys optional
-- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 400+ models like Gemini 3 Pro, Claude 4 Sonnet & Opus, and GPT-5
+- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5
 
 <p align="center">
   <img src="https://media.githubusercontent.com/media/Kilo-Org/kilocode/main/kilo.gif" width="100%" />
@@ -33,11 +34,11 @@
 - **MCP Server Marketplace**: Kilo can easily find, and use MCP servers to extend the agent capabilities.
 - **Multi Mode**: Plan with Architect, Code with Coder, and Debug with Debugger, and make your own custom modes.
 
-## How to get started with Kilo Code
+## How to get started with Kilo
 
 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
-2. Create your account to access 400+ cutting-edge AI models including Gemini 3 Pro, Claude 4 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly.
-3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo Code in action:
+2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly.
+3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
 
 [![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig)
 
@@ -49,7 +50,7 @@ For details on building and developing the extension, see [DEVELOPMENT.md](/DEVE
 
 Contributions are welcome, and they are greatly appreciated! Get started by reading our [Contributing Guide](CONTRIBUTING.md). Or join our [Discord](https://discord.gg/kilocode) to chat with the team and community.
 
-Thanks to all the contributors who help make Kilo Code better!
+Thanks to all the contributors who help make Kilo better!
 
 <table>
   <tr>

+ 87 - 0
apps/kilocode-docs/docs/advanced-usage/agent-manager.md

@@ -0,0 +1,87 @@
+# Agent Manager
+
+The Agent Manager is a dedicated control panel for running and supervising Kilo Code agents as interactive CLI processes. It supports:
+
+- Local sessions
+- Resuming existing sessions
+- Parallel Mode (with support for Git worktree) for safe, isolated changes
+- Viewing and continuing cloud-synced sessions filtered to your current repository
+
+This page reflects the actual implementation in the extension.
+
+## Prerequisites
+
+- Install/update the Kilo Code CLI (latest) — see [CLI setup](/cli)
+- Open a project in VS Code (workspace required)
+
+## Opening the Agent Manager
+
+- Command Palette: “Kilo Code: Open Agent Manager”
+- Or use the title/menu entry if available in your Kilo Code UI
+
+The panel opens as a webview and stays active across focus changes.
+
+## Sending messages, approvals, and control
+
+- Continue the conversation: Send a follow-up message to the running agent
+- Approvals: If the agent asks to use a tool, run a command, launch the browser, or connect to an MCP server, the UI shows an approval prompt
+    - Approve or reject, optionally adding a short note
+- Cancel vs Stop
+    - Cancel sends a structured cancel message to the running process (clean cooperative stop)
+    - Stop force-terminates the underlying CLI process, updating status to “stopped”
+
+## Resuming an existing session
+
+You can continue a session later (local or remote):
+
+- If a session is not currently running, the Agent Manager will spawn a new CLI process attached to that session’s ID
+- Labels from the original session are preserved whenever possible
+- Your first follow-up message becomes the continuation input
+
+## Parallel Mode
+
+Parallel Mode runs the agent in an isolated Git worktree branch, keeping your main branch clean.
+
+- Enable the “Parallel Mode” toggle before starting
+- The extension prevents using Parallel Mode inside an existing worktree
+    - Open the main repository (where .git is a directory) to use this feature
+- While running, the Agent Manager parses and surfaces:
+    - Branch name created/used
+    - Worktree path
+    - A completion/merge instruction message when the agent finishes
+- After completion
+    - Review the branch in your VCS UI
+    - Merge or cherry-pick the changes as desired
+    - Clean up the worktree when finished
+
+If you need to resume with Parallel Mode later, the extension re-attaches to the same session with the same branch context.
+
+## Remote sessions (Cloud)
+
+When signed in (Kilo Cloud), the Agent Manager lists your recent cloud-synced sessions:
+
+- Up to 50 sessions are fetched
+- Sessions are filtered to the current repository via normalized Git remote URL
+    - If the current workspace has no remote, only sessions without a git_url are shown
+- Selecting a remote session loads its message transcript
+- To continue the work locally, send a message — the Agent Manager will spawn a local process bound to that session
+
+Message transcripts are fetched from a signed blob and exclude internal checkpoint “save” markers as chat rows (checkpoints still appear as dedicated entries in the UI).
+
+## Troubleshooting
+
+- CLI not found or outdated
+    - Install/update the CLI: [CLI setup](/cli)
+    - If you see an “unknown option --json-io” error, update to the latest CLI
+- “Please open a folder…” error
+    - The Agent Manager requires a VS Code workspace folder
+- “Cannot use parallel mode from within a git worktree”
+    - Open the main repository (where .git is a directory), not a worktree checkout
+- Remote sessions not visible
+    - Ensure you’re signed in and the repo’s remote URL matches the sessions you expect to see
+
+## Related features
+
+- [Sessions](/advanced-usage/sessions)
+- [Auto-approving Actions](/features/auto-approving-actions)
+- [CLI](/cli)

+ 8 - 7
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**.

+ 13 - 11
apps/kilocode-docs/docs/advanced-usage/managed-indexing.md

@@ -39,28 +39,28 @@ Before enabling Managed Indexing:
 
 ## How to Enable
 
-Codebase Indexing is currently in beta and requires opt-in configuration.
+Codebase Indexing is rolling out across our users. It will automatically engage unless your repository root is configured to opt out.
 
 1. Create a `.kilocode/config.json` file in the root of your repository (if it doesn't already exist).
 2. Add the following configuration:
 
 ```json
 {
-	"projectId": "my-project-name",
-	"baseBranch": "main",
-	"managedIndexingEnabled": true
+	"project": {
+		"managedIndexingEnabled": false
+	}
 }
 ```
 
 ### Configuration Options
 
-| Field                    | Type    | Required | Description                                                                     |
-| ------------------------ | ------- | -------- | ------------------------------------------------------------------------------- |
-| `projectId`              | string  | No       | Custom name for your project. Defaults to the name from your Git origin remote. |
-| `baseBranch`             | string  | No       | Specifies your base branch if it isn't `main`, `master`, `dev`, or `develop`.   |
-| `managedIndexingEnabled` | boolean | No       | Set to `true` to enable indexing for individual accounts. Defaults to `false`.  |
+| Field                            | Type    | Required | Description                                                                                 |
+| -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------- |
+| `project.id`                     | string  | No       | Custom name for your project. Defaults to the name from your Git origin remote.             |
+| `project.baseBranch`             | string  | No       | Specifies your base branch if it isn't `main`, `master`, `dev`, or `develop`.               |
+| `project.managedIndexingEnabled` | boolean | No       | Set to `false` to disable indexing for individual project repositories. Defaults to `true`. |
 
-For organization-wide shared indexing, contact support. This will be rolled out to all organizations within the coming week and will eventually be enabled by default for any account with an available balance.
+Organization-wide indexing is enabled for any organization that has a credit balance. If you want to disable indexing for a specific repository, set `managedIndexingEnabled` to `false` in the config file.
 
 ---
 
@@ -91,7 +91,9 @@ A minimal UI is available at [app.kilo.ai](https://app.kilo.ai) to:
 
 ## Migration from Local Indexing
 
-Enabling managed indexing will **replace local self-hosted indexing entirely**. Any pre-configured local code index will no longer be accessible once managed indexing is active.
+Enabling managed indexing will **replace local self-hosted indexing entirely**. If you have already configured local indexing for a workspace it will take precedence until you disable it.
+
+### Automatic Reversion
 
 If your credit balance reaches zero, the extension will automatically revert to local indexing (if previously configured).
 

+ 58 - 0
apps/kilocode-docs/docs/advanced-usage/sessions.md

@@ -0,0 +1,58 @@
+# Sessions
+
+A session is your platform-agnostic interaction with Kilo. It remembers your repository, your task, and the conversation so you can pause and resume work without losing context. Sessions are private to your account by default; you can optionally share a link with others who can read or fork your session.
+
+## What a session keeps for you
+
+- Repository you chose to work on
+- The conversation with the agent (your prompts and the agent’s replies)
+- Task metadata (what the agent is doing for you)
+- Optional Git context (for example, the repo URL and a lightweight snapshot of state) so the agent can pick up where it left off
+
+This information lets Kilo show your recent sessions and continue right from the same context the next time you open it.
+
+## Quick start: Create a session
+
+1. Choose the repository. Pick the GitHub repository you want the agent to work with.
+2. Describe the task. (e.g., “Add dark mode toggle and unit tests”).
+3. Interact with Kilo via any of our interfaces- the CLI, the Cloud Agent, or the Extensions in your favorite IDE.
+
+## Continue where you left off
+
+1. Open Cloud Agents → Recent Sessions and select the session you want to resume.
+2. The chat will load with your previous messages and context so the agent can keep going without re-explaining your task.
+
+## Share a session (read‑only)
+
+You can share a session with anyone via a link. A shared page:
+
+1. Shows who shared it, the session title, and a short preview of the conversation
+2. Provides safe “open in editor” or CLI actions so collaborators can try your session themselves
+3. Lives at a URL like /share/SHARE_ID and is visible to anyone with the link
+
+Note: Sharing creates a read‑only copy for the public link so your private session remains in your account.
+
+## Fork a shared session (make it yours)
+
+If someone shares a session with you, you can fork it to create your own copy:
+
+- From the share page, choose “Open in Editor” (recommended), or run one of these commands:
+    - CLI: kilocode --fork SHARE_ID
+    - In‑app command: /session fork SHARE_ID
+
+Forking creates a new session in your account, with its own ID, and copies over the relevant context so you can continue independently.
+
+## Where your session data lives
+
+To keep sessions fast and resumable, Kilo stores small JSON blobs associated with your session. These include your conversation history and task metadata. If you share a session, Kilo keeps a public copy used by the share link while your private session remains under your account.
+
+Good practice:
+
+1. Don’t paste secrets into prompts. Use environment variables when needed.
+2. If a share link is created, treat it like any other public link—anyone with it can view the shared copy.
+
+## Power‑user tips
+
+1. Keep your task description focused; you can refine it with follow‑up prompts.
+2. Use setup commands to prepare the environment the agent runs in (e.g., install dependencies).
+3. For collaboration, share and ask teammates to fork; you’ll each have independent progress and costs.

+ 6 - 0
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.

+ 2 - 0
apps/kilocode-docs/sidebars.ts

@@ -161,6 +161,8 @@ const sidebars: SidebarsConfig = {
 				"advanced-usage/code-reviews",
 				"advanced-usage/deploy",
 				"advanced-usage/managed-indexing",
+				"advanced-usage/agent-manager",
+				"advanced-usage/sessions",
 				"features/experimental/experimental-features",
 			],
 		},

+ 6 - 0
cli/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @kilocode/cli
 
+## 0.14.0
+
+### Minor Changes
+
+- [#4291](https://github.com/Kilo-Org/kilocode/pull/4291) [`215c48f`](https://github.com/Kilo-Org/kilocode/commit/215c48f68dca37df435ea619ba8496912e2b4c22) Thanks [@pandemicsyn](https://github.com/pandemicsyn)! - Fix race during session restoration
+
 ## 0.13.1
 
 ### Patch Changes

+ 16 - 2
cli/README.md

@@ -239,9 +239,20 @@ This instructs the AI to proceed without user input.
 
 To build and run the CLI locally off your branch:
 
-#### Install dependencies
+#### Build the VS Code extension
 
 ```shell
+cd src
+pnpm bundle
+pnpm vsix
+pnpm vsix:unpackged
+cd ..
+```
+
+#### Install CLI dependencies
+
+```shell
+cd cli
 pnpm install
 pnpm deps:install
 ```
@@ -249,10 +260,13 @@ pnpm deps:install
 #### Build the CLI
 
 ```shell
+pnpm clean
+pnpm clean:kilocode
+pnpm copy:kilocode
 pnpm build
 ```
 
-#### Configure the settings
+#### Configure CLI settings
 
 ```shell
 pnpm start config

+ 1 - 1
cli/package.dist.json

@@ -1,6 +1,6 @@
 {
 	"name": "@kilocode/cli",
-	"version": "0.13.1",
+	"version": "0.14.0",
 	"description": "Terminal User Interface for Kilo Code",
 	"type": "module",
 	"main": "index.js",

+ 1 - 1
cli/package.json

@@ -1,6 +1,6 @@
 {
 	"name": "@kilocode/cli",
-	"version": "0.13.1",
+	"version": "0.14.0",
 	"description": "Terminal User Interface for Kilo Code",
 	"type": "module",
 	"main": "dist/index.js",

+ 1 - 1
cli/src/cli.ts

@@ -329,7 +329,7 @@ export class CLI {
 		try {
 			logs.info("Disposing Kilo Code CLI...", "CLI")
 
-			await this.sessionService?.destroy()
+			await this.sessionService?.doSync(true)
 
 			// Signal codes take precedence over CI logic
 			if (signal === "SIGINT") {

+ 17 - 37
cli/src/state/atoms/effects.ts

@@ -176,6 +176,23 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
 			return
 		}
 
+		// NOTE: Copied from ClineProvider - make sure the two match.
+		if (message.type === "apiMessagesSaved" && message.payload) {
+			const [taskId, filePath] = message.payload as [string, string]
+
+			SessionManager.init().handleFileUpdate(taskId, "apiConversationHistoryPath", filePath)
+		} else if (message.type === "taskMessagesSaved" && message.payload) {
+			const [taskId, filePath] = message.payload as [string, string]
+
+			SessionManager.init().handleFileUpdate(taskId, "uiMessagesPath", filePath)
+		} else if (message.type === "taskMetadataSaved" && message.payload) {
+			const [taskId, filePath] = message.payload as [string, string]
+
+			SessionManager.init().handleFileUpdate(taskId, "taskMetadataPath", filePath)
+		} else if (message.type === "currentCheckpointUpdated") {
+			SessionManager.init().doSync()
+		}
+
 		// Handle different message types
 		switch (message.type) {
 			case "state":
@@ -377,43 +394,6 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
 				break
 			}
 
-			case "apiMessagesSaved": {
-				const payload = message.payload as [string, string] | undefined
-
-				if (payload && Array.isArray(payload) && payload.length === 2) {
-					const [taskId, filePath] = payload
-
-					SessionManager.init().handleFileUpdate(taskId, "apiConversationHistoryPath", filePath)
-				} else {
-					logs.warn(`[DEBUG] Invalid apiMessagesSaved payload`, "effects", { payload })
-				}
-				break
-			}
-
-			case "taskMessagesSaved": {
-				const payload = message.payload as [string, string] | undefined
-
-				if (payload && Array.isArray(payload) && payload.length === 2) {
-					const [taskId, filePath] = payload
-
-					SessionManager.init().handleFileUpdate(taskId, "uiMessagesPath", filePath)
-				} else {
-					logs.warn(`[DEBUG] Invalid taskMessagesSaved payload`, "effects", { payload })
-				}
-				break
-			}
-
-			case "taskMetadataSaved": {
-				const payload = message.payload as [string, string] | undefined
-				if (payload && Array.isArray(payload) && payload.length === 2) {
-					const [taskId, filePath] = payload
-
-					SessionManager.init().handleFileUpdate(taskId, "taskMetadataPath", filePath)
-				} else {
-					logs.warn(`[DEBUG] Invalid taskMetadataSaved payload`, "effects", { payload })
-				}
-				break
-			}
 			case "commandExecutionStatus": {
 				// Handle command execution status messages
 				// Store output updates and apply them when the ask appears

+ 55 - 2
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<boolean> {
+			async $openUri(uri: UriComponents, uriString: string | undefined, options: any): Promise<boolean> {
 				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<UriComponents> {
 				console.log("As external URI:", { uri, options })

+ 2 - 0
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
     }
 

+ 41 - 6
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<Map<String, Any?>>, 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)
+        }
     }
 }

+ 13 - 20
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) {

+ 80 - 0
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
+    }
+}

+ 122 - 0
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<String, Any?>()
+    
+    /**
+     * 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<String, Any?> {
+        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)
+        }
+    }
+}

+ 1 - 5
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())

+ 0 - 4
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

+ 17 - 0
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
+}

+ 233 - 0
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<Int, ProviderRegistration>()
+    
+    /**
+     * 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<Map<String, Any?>>,
+        supportsHandleDidShowCompletionItem: Boolean,
+        extensionId: String,
+        yieldsToExtensionIds: List<String>,
+        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<String, Any?>,
+        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<Map<String, Any?>>,
+        val extensionId: String,
+        val yieldsToExtensionIds: List<String>
+    )
+}

+ 251 - 0
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<String?>(null)
+
+    /**
+     * Result wrapper for inline completion operations.
+     */
+    sealed class Result {
+        data class Success(val items: List<CompletionItem>) : 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)
+        }
+    }
+}

+ 134 - 0
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<PositionInfo, Throwable> {
+                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
+    )
+}

+ 1 - 0
jetbrains/plugin/src/main/resources/META-INF/plugin.xml.template

@@ -56,6 +56,7 @@ SPDX-License-Identifier: Apache-2.0
         />
         <checkinHandlerFactory implementation="ai.kilocode.jetbrains.git.CommitMessageHandlerFactory"/>
         <applicationService serviceImplementation="ai.kilocode.jetbrains.git.CommitMessageService"/>
+        <applicationService serviceImplementation="ai.kilocode.jetbrains.inline.InlineCompletionService"/>
     </extensions>
 
     <extensions defaultExtensionNs="org.jetbrains.plugins.terminal">

+ 1 - 0
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
 

+ 51 - 0
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<typeof DeviceAuthInitiateResponseSchema>
+
+/**
+ * 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<typeof DeviceAuthPollResponseSchema>
+
+/**
+ * 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
+}

+ 1 - 0
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(),
 	})

+ 11 - 0
packages/types/src/telemetry.ts

@@ -50,6 +50,12 @@ export enum TelemetryEventName {
 	GHOST_SERVICE_DISABLED = "Ghost Service Disabled",
 	ASK_APPROVAL = "Ask Approval",
 	MISSING_MANAGED_INDEXER = "Missing Managed Indexer",
+
+	AGENT_MANAGER_OPENED = "Agent Manager Opened",
+	AGENT_MANAGER_SESSION_STARTED = "Agent Manager Session Started",
+	AGENT_MANAGER_SESSION_COMPLETED = "Agent Manager Session Completed",
+	AGENT_MANAGER_SESSION_STOPPED = "Agent Manager Session Stopped",
+	AGENT_MANAGER_SESSION_ERROR = "Agent Manager Session Error",
 	// kilocode_change end
 
 	TASK_CREATED = "Task Created",
@@ -224,6 +230,11 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
 			TelemetryEventName.AUTO_PURGE_FAILED, // kilocode_change
 			TelemetryEventName.MANUAL_PURGE_TRIGGERED, // kilocode_change
 			TelemetryEventName.GHOST_SERVICE_DISABLED, // kilocode_change
+			TelemetryEventName.AGENT_MANAGER_OPENED, // kilocode_change
+			TelemetryEventName.AGENT_MANAGER_SESSION_STARTED, // kilocode_change
+			TelemetryEventName.AGENT_MANAGER_SESSION_COMPLETED, // kilocode_change
+			TelemetryEventName.AGENT_MANAGER_SESSION_STOPPED, // kilocode_change
+			TelemetryEventName.AGENT_MANAGER_SESSION_ERROR, // kilocode_change
 			// kilocode_change end
 
 			TelemetryEventName.TASK_CREATED,

+ 2 - 1
src/api/providers/kilocode-openrouter.ts

@@ -14,6 +14,7 @@ import {
 	X_KILOCODE_PROJECTID,
 	X_KILOCODE_TESTER,
 } from "../../shared/kilocode/headers"
+import { KILOCODE_TOKEN_REQUIRED_ERROR } from "../../shared/kilocode/errorUtils"
 import { DEFAULT_HEADERS } from "./constants"
 import { streamSse } from "../../services/continuedev/core/fetch/stream"
 
@@ -114,7 +115,7 @@ export class KilocodeOpenrouterHandler extends OpenRouterHandler {
 
 	public override async fetchModel() {
 		if (!this.options.kilocodeToken || !this.options.openRouterBaseUrl) {
-			throw new Error("KiloCode token + baseUrl is required to fetch models")
+			throw new Error(KILOCODE_TOKEN_REQUIRED_ERROR)
 		}
 
 		const [models, endpoints, defaultModel] = await Promise.all([

+ 46 - 3
src/core/kilocode/agent-manager/AgentManagerProvider.ts

@@ -21,6 +21,13 @@ import { getViteDevServerConfig } from "../../webview/getViteDevServerConfig"
 import { getRemoteUrl } from "../../../services/code-index/managed/git-utils"
 import { normalizeGitUrl } from "./normalizeGitUrl"
 import type { ClineMessage } from "@roo-code/types"
+import {
+	captureAgentManagerOpened,
+	captureAgentManagerSessionStarted,
+	captureAgentManagerSessionCompleted,
+	captureAgentManagerSessionStopped,
+	captureAgentManagerSessionError,
+} from "./telemetry"
 
 /**
  * AgentManagerProvider
@@ -93,6 +100,12 @@ export class AgentManagerProvider implements vscode.Disposable {
 					if (sawApiReqStarted) {
 						this.firstApiReqStarted.set(latestSession.sessionId, true)
 					}
+
+					// Track session started telemetry
+					captureAgentManagerSessionStarted(
+						latestSession.sessionId,
+						latestSession.parallelMode?.enabled ?? false,
+					)
 				}
 			},
 		}
@@ -154,6 +167,9 @@ export class AgentManagerProvider implements vscode.Disposable {
 		)
 
 		this.outputChannel.appendLine("Agent Manager panel opened")
+
+		// Track Agent Manager panel opened
+		captureAgentManagerOpened()
 	}
 
 	private handleMessage(message: { type: string; [key: string]: unknown }): void {
@@ -193,6 +209,9 @@ export class AgentManagerProvider implements vscode.Disposable {
 				case "agentManager.removeSession":
 					this.removeSession(message.sessionId as string)
 					break
+				case "agentManager.cancelPendingSession":
+					this.cancelPendingSession()
+					break
 				case "agentManager.selectSession":
 					this.selectSession(message.sessionId as string | null)
 					break
@@ -312,22 +331,34 @@ export class AgentManagerProvider implements vscode.Disposable {
 				this.parseParallelModeOutput(sessionId, event.content)
 				this.log(sessionId, `[${event.source}] ${event.content}`)
 				break
-			case "error":
+			case "error": {
+				const session = this.registry.getSession(sessionId)
 				this.registry.updateSessionStatus(sessionId, "error", undefined, event.error)
 				this.log(sessionId, `Error: ${event.error}`)
 				if (event.details) {
 					this.log(sessionId, `Details: ${JSON.stringify(event.details)}`)
 				}
+				// Track session error telemetry
+				captureAgentManagerSessionError(sessionId, session?.parallelMode?.enabled ?? false, event.error)
 				break
-			case "complete":
+			}
+			case "complete": {
+				const session = this.registry.getSession(sessionId)
 				this.registry.updateSessionStatus(sessionId, "done", event.exitCode)
 				this.log(sessionId, "Agent completed")
 				void this.fetchAndPostRemoteSessions()
+				// Track session completed telemetry
+				captureAgentManagerSessionCompleted(sessionId, session?.parallelMode?.enabled ?? false)
 				break
-			case "interrupted":
+			}
+			case "interrupted": {
+				const session = this.registry.getSession(sessionId)
 				this.registry.updateSessionStatus(sessionId, "stopped", undefined, event.reason)
 				this.log(sessionId, event.reason || "Execution interrupted")
+				// Track session stopped telemetry
+				captureAgentManagerSessionStopped(sessionId, session?.parallelMode?.enabled ?? false)
 				break
+			}
 			case "session_created":
 				// Handled by CliProcessManager
 				break
@@ -468,6 +499,8 @@ export class AgentManagerProvider implements vscode.Disposable {
 	 * Stop a running agent session
 	 */
 	private stopAgentSession(sessionId: string): void {
+		const session = this.registry.getSession(sessionId)
+
 		this.processHandler.stopProcess(sessionId)
 
 		this.registry.updateSessionStatus(sessionId, "stopped", undefined, "Stopped by user")
@@ -475,6 +508,9 @@ export class AgentManagerProvider implements vscode.Disposable {
 		this.postStateToWebview()
 
 		this.firstApiReqStarted.delete(sessionId)
+
+		// Track session stopped telemetry
+		captureAgentManagerSessionStopped(sessionId, session?.parallelMode?.enabled ?? false)
 	}
 
 	/**
@@ -562,6 +598,13 @@ export class AgentManagerProvider implements vscode.Disposable {
 		}
 	}
 
+	/**
+	 * Cancel a pending session that is stuck in "Creating session..." state
+	 */
+	private cancelPendingSession(): void {
+		this.processHandler.cancelPendingSession()
+	}
+
 	/**
 	 * Remove a session completely
 	 */

+ 63 - 3
src/core/kilocode/agent-manager/CliProcessHandler.ts

@@ -10,6 +10,12 @@ import { AgentRegistry } from "./AgentRegistry"
 import { buildCliArgs } from "./CliArgsBuilder"
 import type { ClineMessage } from "@roo-code/types"
 
+/**
+ * Timeout for pending sessions (ms) - if session_created event doesn't arrive within this time,
+ * the session is considered failed. This prevents the UI from getting stuck in "Creating session..." state.
+ */
+const PENDING_SESSION_TIMEOUT_MS = 30_000
+
 /**
  * Tracks a pending session while waiting for CLI's session_created event.
  * Note: This is only used for NEW sessions. Resume sessions go directly to activeSessions.
@@ -26,6 +32,7 @@ interface PendingProcessInfo {
 	sawApiReqStarted?: boolean // Track if api_req_started arrived before session_created
 	gitUrl?: string
 	stderrBuffer: string[] // Capture stderr for error detection
+	timeoutId?: NodeJS.Timeout // Timer for auto-failing stuck pending sessions
 }
 
 interface ActiveProcessInfo {
@@ -58,6 +65,13 @@ export class CliProcessHandler {
 		this.callbacks.onDebugLog?.(message)
 	}
 
+	/** Clear the pending session timeout if it exists */
+	private clearPendingTimeout(): void {
+		if (this.pendingProcess?.timeoutId) {
+			clearTimeout(this.pendingProcess.timeoutId)
+		}
+	}
+
 	public spawnProcess(
 		cliPath: string,
 		workspace: string,
@@ -106,7 +120,7 @@ export class CliProcessHandler {
 		const proc = spawn(cliPath, cliArgs, {
 			cwd: workspace,
 			stdio: ["pipe", "pipe", "pipe"],
-			env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
+			env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0", KILO_PLATFORM: "agent-manager" },
 			shell: false,
 		})
 
@@ -148,6 +162,7 @@ export class CliProcessHandler {
 				desiredLabel: options?.label,
 				gitUrl: options?.gitUrl,
 				stderrBuffer: [],
+				timeoutId: setTimeout(() => this.handlePendingTimeout(), PENDING_SESSION_TIMEOUT_MS),
 			}
 		}
 
@@ -199,6 +214,7 @@ export class CliProcessHandler {
 	public stopAllProcesses(): void {
 		// Stop pending process if any
 		if (this.pendingProcess) {
+			this.clearPendingTimeout()
 			this.pendingProcess.process.kill("SIGTERM")
 			this.registry.clearPendingSession()
 			this.pendingProcess = null
@@ -210,6 +226,26 @@ export class CliProcessHandler {
 		this.activeSessions.clear()
 	}
 
+	/**
+	 * Cancel a pending session that hasn't received session_created yet.
+	 * This allows users to manually cancel stuck session creation.
+	 */
+	public cancelPendingSession(): void {
+		if (!this.pendingProcess) {
+			return
+		}
+
+		this.debugLog(`Canceling pending session`)
+
+		this.clearPendingTimeout()
+		this.pendingProcess.process.kill("SIGTERM")
+		this.registry.clearPendingSession()
+		this.pendingProcess = null
+
+		this.callbacks.onPendingSessionChanged(null)
+		this.callbacks.onStateChanged()
+	}
+
 	public hasProcess(sessionId: string): boolean {
 		return this.activeSessions.has(sessionId)
 	}
@@ -293,12 +329,36 @@ export class CliProcessHandler {
 		}
 	}
 
+	private handlePendingTimeout(): void {
+		if (!this.pendingProcess) {
+			return
+		}
+
+		this.callbacks.onLog(
+			`Pending session timed out after ${PENDING_SESSION_TIMEOUT_MS / 1000}s - no session_created event received`,
+		)
+
+		const stderrOutput = this.pendingProcess.stderrBuffer.join("\n")
+		this.pendingProcess.process.kill("SIGTERM")
+		this.registry.clearPendingSession()
+		this.pendingProcess = null
+
+		this.callbacks.onPendingSessionChanged(null)
+		this.callbacks.onStartSessionFailed({
+			type: "unknown",
+			message: stderrOutput || "Session creation timed out - CLI did not respond",
+		})
+		this.callbacks.onStateChanged()
+	}
+
 	private handleSessionCreated(event: SessionCreatedStreamEvent): void {
 		if (!this.pendingProcess) {
 			this.debugLog(`Received session_created but no pending process`)
 			return
 		}
 
+		this.clearPendingTimeout()
+
 		const {
 			process: proc,
 			prompt,
@@ -368,8 +428,8 @@ export class CliProcessHandler {
 		signal: NodeJS.Signals | null,
 		onCliEvent: (sessionId: string, event: StreamEvent) => void,
 	): void {
-		// Check if this is the pending process (only for NEW sessions, not resumes)
 		if (this.pendingProcess && this.pendingProcess.process === proc) {
+			this.clearPendingTimeout()
 			const stderrOutput = this.pendingProcess.stderrBuffer.join("\n")
 			this.registry.clearPendingSession()
 			this.callbacks.onPendingSessionChanged(null)
@@ -418,8 +478,8 @@ export class CliProcessHandler {
 	}
 
 	private handleProcessError(proc: ChildProcess, error: Error): void {
-		// Check if this is the pending process (only for NEW sessions, not resumes)
 		if (this.pendingProcess && this.pendingProcess.process === proc) {
+			this.clearPendingTimeout()
 			this.registry.clearPendingSession()
 			this.callbacks.onPendingSessionChanged(null)
 			this.pendingProcess = null

+ 182 - 0
src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts

@@ -1,8 +1,18 @@
 import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"
 import { EventEmitter } from "node:events"
+import * as telemetry from "../telemetry"
 
 const MOCK_CLI_PATH = "/mock/path/to/kilocode"
 
+// Mock the local telemetry module
+vi.mock("../telemetry", () => ({
+	captureAgentManagerOpened: vi.fn(),
+	captureAgentManagerSessionStarted: vi.fn(),
+	captureAgentManagerSessionCompleted: vi.fn(),
+	captureAgentManagerSessionStopped: vi.fn(),
+	captureAgentManagerSessionError: vi.fn(),
+}))
+
 let AgentManagerProvider: typeof import("../AgentManagerProvider").AgentManagerProvider
 
 describe("AgentManagerProvider CLI spawning", () => {
@@ -567,3 +577,175 @@ describe("AgentManagerProvider gitUrl filtering", () => {
 		})
 	})
 })
+
+describe("AgentManagerProvider telemetry", () => {
+	let provider: InstanceType<typeof AgentManagerProvider>
+	const mockContext = { extensionUri: {}, extensionPath: "", extensionMode: 1 /* Development */ } as any
+	const mockOutputChannel = { appendLine: vi.fn() } as any
+
+	beforeEach(async () => {
+		vi.resetModules()
+		vi.clearAllMocks()
+
+		const mockWorkspaceFolder = { uri: { fsPath: "/tmp/workspace" } }
+		const mockWindow = { showErrorMessage: () => undefined, ViewColumn: { One: 1 } }
+
+		vi.doMock("vscode", () => ({
+			workspace: { workspaceFolders: [mockWorkspaceFolder] },
+			window: mockWindow,
+			env: { openExternal: vi.fn() },
+			Uri: { parse: vi.fn(), joinPath: vi.fn() },
+			ViewColumn: { One: 1 },
+			ExtensionMode: { Development: 1, Production: 2, Test: 3 },
+		}))
+
+		vi.doMock("../../../../utils/fs", () => ({
+			fileExistsAtPath: vi.fn().mockResolvedValue(false),
+		}))
+
+		vi.doMock("../../../../services/code-index/managed/git-utils", () => ({
+			getRemoteUrl: vi.fn().mockResolvedValue(undefined),
+		}))
+
+		class TestProc extends EventEmitter {
+			stdout = new EventEmitter()
+			stderr = new EventEmitter()
+			kill = vi.fn()
+			pid = 1234
+		}
+
+		const spawnMock = vi.fn(() => new TestProc())
+		const execSyncMock = vi.fn(() => MOCK_CLI_PATH)
+
+		vi.doMock("node:child_process", () => ({
+			spawn: spawnMock,
+			execSync: execSyncMock,
+		}))
+
+		const module = await import("../AgentManagerProvider")
+		AgentManagerProvider = module.AgentManagerProvider
+		provider = new AgentManagerProvider(mockContext, mockOutputChannel)
+	})
+
+	afterEach(() => {
+		provider.dispose()
+	})
+
+	it("tracks session started telemetry when session_created event is received", async () => {
+		await (provider as any).startAgentSession("test telemetry")
+		const spawnMock = (await import("node:child_process")).spawn as unknown as Mock
+		const proc = spawnMock.mock.results[0].value as EventEmitter & { stdout: EventEmitter }
+
+		// Emit session_created event
+		proc.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-telemetry-1"}\n'))
+
+		expect(telemetry.captureAgentManagerSessionStarted).toHaveBeenCalledWith(
+			"session-telemetry-1",
+			false, // useWorktree = false (no parallel mode)
+		)
+	})
+
+	it("tracks session started with worktree flag for parallel mode sessions", async () => {
+		await (provider as any).startAgentSession("test parallel", { parallelMode: true })
+		const spawnMock = (await import("node:child_process")).spawn as unknown as Mock
+		const proc = spawnMock.mock.results[0].value as EventEmitter & { stdout: EventEmitter }
+
+		// Emit session_created event
+		proc.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-parallel-1"}\n'))
+
+		expect(telemetry.captureAgentManagerSessionStarted).toHaveBeenCalledWith(
+			"session-parallel-1",
+			true, // useWorktree = true (parallel mode enabled)
+		)
+	})
+
+	it("tracks session completed telemetry when complete event is received", async () => {
+		// Create a session directly in the registry
+		const registry = (provider as any).registry
+		const sessionId = "session-complete-1"
+		registry.createSession(sessionId, "test complete")
+		;(provider as any).sessionMessages.set(sessionId, [])
+
+		// Handle complete event
+		;(provider as any).handleCliEvent(sessionId, {
+			streamEventType: "complete",
+			exitCode: 0,
+		})
+
+		expect(telemetry.captureAgentManagerSessionCompleted).toHaveBeenCalledWith(
+			sessionId,
+			false, // useWorktree = false
+		)
+	})
+
+	it("tracks session stopped telemetry when user stops a session", async () => {
+		// Create a session directly in the registry
+		const registry = (provider as any).registry
+		const sessionId = "session-stop-1"
+		registry.createSession(sessionId, "test stop")
+		registry.updateSessionStatus(sessionId, "running")
+
+		// Stop the session
+		;(provider as any).stopAgentSession(sessionId)
+
+		expect(telemetry.captureAgentManagerSessionStopped).toHaveBeenCalledWith(
+			sessionId,
+			false, // useWorktree = false
+		)
+	})
+
+	it("tracks session stopped telemetry when interrupted event is received", async () => {
+		const registry = (provider as any).registry
+		const sessionId = "session-interrupted-1"
+		registry.createSession(sessionId, "test interrupted")
+		;(provider as any).sessionMessages.set(sessionId, [])
+
+		// Handle interrupted event
+		;(provider as any).handleCliEvent(sessionId, {
+			streamEventType: "interrupted",
+			reason: "User cancelled",
+		})
+
+		expect(telemetry.captureAgentManagerSessionStopped).toHaveBeenCalledWith(
+			sessionId,
+			false, // useWorktree = false
+		)
+	})
+
+	it("tracks session error telemetry when error event is received", async () => {
+		const registry = (provider as any).registry
+		const sessionId = "session-error-1"
+		registry.createSession(sessionId, "test error")
+		;(provider as any).sessionMessages.set(sessionId, [])
+
+		// Handle error event
+		;(provider as any).handleCliEvent(sessionId, {
+			streamEventType: "error",
+			error: "Something went wrong",
+		})
+
+		expect(telemetry.captureAgentManagerSessionError).toHaveBeenCalledWith(
+			sessionId,
+			false, // useWorktree = false
+			"Something went wrong",
+		)
+	})
+
+	it("tracks worktree flag correctly for parallel mode sessions in completion", async () => {
+		const registry = (provider as any).registry
+		const sessionId = "session-parallel-complete-1"
+		registry.createSession(sessionId, "test parallel complete", undefined, { parallelMode: true })
+		;(provider as any).sessionMessages.set(sessionId, [])
+
+		// Handle complete event
+		;(provider as any).handleCliEvent(sessionId, {
+			streamEventType: "complete",
+			exitCode: 0,
+		})
+
+		expect(telemetry.captureAgentManagerSessionCompleted).toHaveBeenCalledWith(
+			sessionId,
+			true, // useWorktree = true (parallel mode)
+		)
+	})
+})

+ 148 - 0
src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts

@@ -152,6 +152,21 @@ describe("CliProcessHandler", () => {
 				}),
 			)
 		})
+
+		it("sets KILO_PLATFORM environment variable to agent-manager", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			expect(spawnMock).toHaveBeenCalledWith(
+				expect.any(String),
+				expect.any(Array),
+				expect.objectContaining({
+					env: expect.objectContaining({
+						KILO_PLATFORM: "agent-manager",
+					}),
+				}),
+			)
+		})
 	})
 
 	describe("session_created event handling", () => {
@@ -590,6 +605,139 @@ describe("CliProcessHandler", () => {
 		})
 	})
 
+	describe("pending session timeout", () => {
+		it("times out pending session after 30 seconds if no session_created event", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			expect(registry.pendingSession).not.toBeNull()
+
+			// Advance time by 30 seconds
+			vi.advanceTimersByTime(30_000)
+
+			// Pending session should be cleared
+			expect(registry.pendingSession).toBeNull()
+			expect(callbacks.onPendingSessionChanged).toHaveBeenLastCalledWith(null)
+			expect(callbacks.onStartSessionFailed).toHaveBeenCalledWith({
+				type: "unknown",
+				message: "Session creation timed out - CLI did not respond",
+			})
+			expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM")
+		})
+
+		it("includes stderr output in timeout error message", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			// Emit some stderr output before timeout
+			mockProcess.stderr.emit("data", Buffer.from("Some error output"))
+
+			// Advance time by 30 seconds
+			vi.advanceTimersByTime(30_000)
+
+			expect(callbacks.onStartSessionFailed).toHaveBeenCalledWith({
+				type: "unknown",
+				message: "Some error output",
+			})
+		})
+
+		it("does not timeout if session_created event arrives in time", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			// Advance time by 15 seconds (half the timeout)
+			vi.advanceTimersByTime(15_000)
+
+			// Emit session_created event
+			mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n'))
+
+			// Advance time past the original timeout
+			vi.advanceTimersByTime(20_000)
+
+			// Session should still exist and not be timed out
+			expect(registry.getSession("session-1")).toBeDefined()
+			expect(registry.getSession("session-1")?.status).toBe("running")
+			expect(callbacks.onStartSessionFailed).not.toHaveBeenCalled()
+		})
+
+		it("clears timeout when process exits before timeout", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			// Process exits with error before timeout
+			mockProcess.emit("exit", 1, null)
+
+			// Advance time past the timeout
+			vi.advanceTimersByTime(35_000)
+
+			// onStartSessionFailed should only have been called once (from exit, not timeout)
+			expect(callbacks.onStartSessionFailed).toHaveBeenCalledTimes(1)
+		})
+
+		it("clears timeout when process errors before timeout", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			// Process errors before timeout
+			mockProcess.emit("error", new Error("spawn ENOENT"))
+
+			// Advance time past the timeout
+			vi.advanceTimersByTime(35_000)
+
+			// onStartSessionFailed should only have been called once (from error, not timeout)
+			expect(callbacks.onStartSessionFailed).toHaveBeenCalledTimes(1)
+		})
+
+		it("clears timeout when stopAllProcesses is called", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			handler.stopAllProcesses()
+
+			// Advance time past the timeout
+			vi.advanceTimersByTime(35_000)
+
+			// onStartSessionFailed should not have been called (stopAllProcesses doesn't call it)
+			expect(callbacks.onStartSessionFailed).not.toHaveBeenCalled()
+		})
+	})
+
+	describe("cancelPendingSession", () => {
+		it("cancels a pending session and kills the process", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			expect(registry.pendingSession).not.toBeNull()
+
+			handler.cancelPendingSession()
+
+			expect(registry.pendingSession).toBeNull()
+			expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM")
+			expect(callbacks.onPendingSessionChanged).toHaveBeenLastCalledWith(null)
+			expect(callbacks.onStateChanged).toHaveBeenCalled()
+		})
+
+		it("does nothing when no pending session exists", () => {
+			handler.cancelPendingSession()
+
+			expect(mockProcess.kill).not.toHaveBeenCalled()
+			expect(callbacks.onPendingSessionChanged).not.toHaveBeenCalled()
+		})
+
+		it("clears the timeout when canceling", () => {
+			const onCliEvent = vi.fn()
+			handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent)
+
+			handler.cancelPendingSession()
+
+			// Advance time past the timeout
+			vi.advanceTimersByTime(35_000)
+
+			// onStartSessionFailed should not have been called (cancel doesn't trigger failure)
+			expect(callbacks.onStartSessionFailed).not.toHaveBeenCalled()
+		})
+	})
+
 	describe("gitUrl support", () => {
 		it("passes gitUrl to registry when creating pending session", () => {
 			const onCliEvent = vi.fn()

+ 40 - 0
src/core/kilocode/agent-manager/telemetry.ts

@@ -0,0 +1,40 @@
+import { TelemetryService } from "@roo-code/telemetry"
+import { TelemetryEventName } from "@roo-code/types"
+
+/**
+ * Agent Manager telemetry helpers.
+ * These functions encapsulate the TelemetryService.hasInstance() check
+ * and keep telemetry logic co-located with the agent-manager feature.
+ */
+
+export function captureAgentManagerOpened(): void {
+	if (!TelemetryService.hasInstance()) return
+	TelemetryService.instance.captureEvent(TelemetryEventName.AGENT_MANAGER_OPENED)
+}
+
+export function captureAgentManagerSessionStarted(sessionId: string, useWorktree: boolean): void {
+	if (!TelemetryService.hasInstance()) return
+	TelemetryService.instance.captureEvent(TelemetryEventName.AGENT_MANAGER_SESSION_STARTED, { sessionId, useWorktree })
+}
+
+export function captureAgentManagerSessionCompleted(sessionId: string, useWorktree: boolean): void {
+	if (!TelemetryService.hasInstance()) return
+	TelemetryService.instance.captureEvent(TelemetryEventName.AGENT_MANAGER_SESSION_COMPLETED, {
+		sessionId,
+		useWorktree,
+	})
+}
+
+export function captureAgentManagerSessionStopped(sessionId: string, useWorktree: boolean): void {
+	if (!TelemetryService.hasInstance()) return
+	TelemetryService.instance.captureEvent(TelemetryEventName.AGENT_MANAGER_SESSION_STOPPED, { sessionId, useWorktree })
+}
+
+export function captureAgentManagerSessionError(sessionId: string, useWorktree: boolean, error?: string): void {
+	if (!TelemetryService.hasInstance()) return
+	TelemetryService.instance.captureEvent(TelemetryEventName.AGENT_MANAGER_SESSION_ERROR, {
+		sessionId,
+		useWorktree,
+		error,
+	})
+}

+ 150 - 0
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<void>
+	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<void> {
+		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
+		}
+	}
+}

+ 65 - 0
src/core/kilocode/webview/webviewMessageHandlerUtils.ts

@@ -6,6 +6,7 @@ import { WebviewMessage } from "../../../shared/WebviewMessage"
 import { Task } from "../../task/Task"
 import axios from "axios"
 import { getKiloUrlFromToken } from "@roo-code/types"
+import { buildApiHandler } from "../../../api"
 
 const shownNativeNotificationIds = new Set<string>()
 
@@ -247,3 +248,67 @@ 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<boolean> => {
+	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 and activate it
+						const { ...profileConfig } = await provider.providerSettingsManager.getProfile({
+							name: profileName,
+						})
+						await provider.upsertProviderProfile(
+							profileName,
+							{
+								...profileConfig,
+								apiProvider: "kilocode",
+								kilocodeToken: token,
+							},
+							true, // Activate immediately to match old handleKiloCodeCallback behavior
+						)
+					} else {
+						// Save to current profile (from welcome screen) and activate
+						const { apiConfiguration, currentApiConfigName = "default" } = await provider.getState()
+						await provider.upsertProviderProfile(currentApiConfigName, {
+							...apiConfiguration,
+							apiProvider: "kilocode",
+							kilocodeToken: token,
+						}) // activate: true by default
+					}
+
+					// Update current task's API handler if exists (matching old implementation)
+					if (provider.getCurrentTask()) {
+						provider.getCurrentTask()!.api = buildApiHandler({
+							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
+	}
+}

+ 3 - 0
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,
 	}
 }

+ 30 - 5
src/core/webview/ClineProvider.ts

@@ -113,6 +113,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<ReturnType<ClineProvider["getState"]>>
 // kilocode_change end
@@ -160,6 +161,7 @@ export class ClineProvider
 	private taskEventListeners: WeakMap<Task, Array<() => 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<string, PendingEditOperation> = new Map()
@@ -224,8 +226,10 @@ export class ClineProvider
 
 			// Create named listener functions so we can remove them later.
 			const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId)
-			const onTaskCompleted = (taskId: string, tokenUsage: any, toolUsage: any) =>
-				this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage)
+			const onTaskCompleted = (taskId: string, tokenUsage: any, toolUsage: any) => {
+				SessionManager.init().doSync(true) // kilocode_change
+				return this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage)
+			}
 			const onTaskAborted = async () => {
 				this.emit(RooCodeEventName.TaskAborted, instance.taskId)
 
@@ -672,7 +676,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
@@ -1141,7 +1145,8 @@ ${prompt}
 	}
 
 	public async postMessageToWebview(message: ExtensionMessage) {
-		await kilo_execIfExtension(() => {
+		// NOTE: Changing this? Update effects.ts in the cli too.
+		kilo_execIfExtension(() => {
 			if (message.type === "apiMessagesSaved" && message.payload) {
 				const [taskId, filePath] = message.payload as [string, string]
 
@@ -1154,6 +1159,8 @@ ${prompt}
 				const [taskId, filePath] = message.payload as [string, string]
 
 				SessionManager.init().handleFileUpdate(taskId, "taskMetadataPath", filePath)
+			} else if (message.type === "currentCheckpointUpdated") {
+				SessionManager.init().doSync()
 			}
 		})
 
@@ -1743,7 +1750,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()
@@ -1763,6 +1770,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
 

+ 49 - 1
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"])
 
@@ -3679,7 +3685,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) {
@@ -4054,6 +4072,8 @@ export const webviewMessageHandler = async (
 
 				const sessionService = SessionManager.init()
 
+				await provider.clearTask()
+
 				await sessionService.forkSession(message.shareId, true)
 
 				await provider.postStateToWebview()
@@ -4065,6 +4085,26 @@ export const webviewMessageHandler = async (
 			}
 			break
 		}
+		case "sessionSelect": {
+			try {
+				if (!message.sessionId) {
+					vscode.window.showErrorMessage("Session ID is required for selecting a session")
+					break
+				}
+
+				const sessionService = SessionManager.init()
+
+				await provider.clearTask()
+
+				await sessionService.restoreSession(message.sessionId, true)
+
+				await provider.postStateToWebview()
+			} catch (error) {
+				const errorMessage = error instanceof Error ? error.message : String(error)
+				vscode.window.showErrorMessage(`Failed to restore session: ${errorMessage}`)
+			}
+			break
+		}
 		case "singleCompletion": {
 			try {
 				const { text, completionRequestId } = message
@@ -4165,6 +4205,14 @@ export const webviewMessageHandler = async (
 			break
 		}
 
+		// 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}`)
 			//

+ 10 - 7
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
 

+ 1 - 1
src/package.json

@@ -3,7 +3,7 @@
 	"displayName": "%extension.displayName%",
 	"description": "%extension.description%",
 	"publisher": "kilocode",
-	"version": "4.132.0",
+	"version": "4.134.0",
 	"icon": "assets/icons/logo-outline-black.png",
 	"galleryBanner": {
 		"color": "#FFFFFF",

+ 34 - 42
src/services/code-index/managed/ManagedIndexer.ts

@@ -11,7 +11,7 @@ import { GitWatcher, GitWatcherEvent } from "../../../shared/GitWatcher"
 import { getCurrentBranch, isGitRepository, getCurrentCommitSha, getBaseBranch } from "./git-utils"
 import { getKilocodeConfig } from "../../../utils/kilo-config-file"
 import { getGitRepositoryInfo } from "../../../utils/git"
-import { getServerManifest, searchCode, upsertFile, deleteFiles } from "./api-client"
+import { getServerManifest, searchCode, upsertFile, deleteFiles, isEnabled } from "./api-client"
 import { ServerManifest } from "./types"
 import { scannerExtensions } from "../shared/supported-extensions"
 import { VectorStoreSearchResult } from "../interfaces/vector-store"
@@ -65,7 +65,8 @@ interface ManagedIndexerWorkspaceFolderState {
 
 export class ManagedIndexer implements vscode.Disposable {
 	private static prevInstance: ManagedIndexer | null = null
-	private enabledViaConfig: boolean = false
+	private disabledViaConfig: boolean = false
+	private enabledViaApi: boolean = false
 
 	static getInstance(): ManagedIndexer {
 		if (!ManagedIndexer.prevInstance) {
@@ -125,7 +126,7 @@ export class ManagedIndexer implements vscode.Disposable {
 	// code right now. We need to clean this up to be more stateless or better rely
 	// on proper memoization/invalidation techniques
 
-	async fetchConfig(): Promise<ManagedIndexerConfig> {
+	fetchConfig(): ManagedIndexerConfig {
 		// kilocode_change: Read directly from ContextProxy instead of ClineProvider
 		const kilocodeToken = this.contextProxy?.getSecret("kilocodeToken")
 		const kilocodeOrganizationId = this.contextProxy?.getValue("kilocodeOrganizationId")
@@ -140,37 +141,16 @@ export class ManagedIndexer implements vscode.Disposable {
 		return this.config
 	}
 
-	async fetchOrganization(): Promise<KiloOrganization | null> {
-		const config = await this.fetchConfig()
-
-		if (config.kilocodeToken && config.kilocodeOrganizationId) {
-			this.organization = await OrganizationService.fetchOrganization(
-				config.kilocodeToken,
-				config.kilocodeOrganizationId,
-				config.kilocodeTesterWarningsDisabledUntil ?? undefined,
-			)
-
-			return this.organization
-		}
-
-		this.organization = null
-
-		return this.organization
-	}
-
 	isEnabled(): boolean {
-		if (this.enabledViaConfig) {
-			return true
+		if (this.disabledViaConfig) {
+			return false
 		}
 
-		const organization = this.organization
-
-		if (!organization) {
-			return false
+		if (this.enabledViaApi) {
+			return true
 		}
 
-		const isEnabled = OrganizationService.isCodeIndexingEnabled(organization)
-		return isEnabled
+		return false
 	}
 
 	/**
@@ -221,6 +201,21 @@ export class ManagedIndexer implements vscode.Disposable {
 	async start() {
 		console.log("[ManagedIndexer] Starting ManagedIndexer")
 
+		this.fetchConfig()
+		const { kilocodeOrganizationId, kilocodeToken } = this.config ?? {}
+
+		if (!kilocodeToken) {
+			console.log("[ManagedIndexer] No Kilocode token found, skipping managed indexing")
+			return
+		}
+
+		// do not use managed indexing if local codebase indexing is already enabled
+		const localIndexingConfig = this.contextProxy?.getGlobalState("codebaseIndexConfig")
+		if (localIndexingConfig?.codebaseIndexEnabled) {
+			console.log("[ManagedIndexer] Local codebase indexing is enabled, skipping managed indexing")
+			return
+		}
+
 		this.configChangeListener = this.contextProxy?.onManagedIndexerConfigChange(
 			this.onConfigurationChange.bind(this),
 		)
@@ -235,12 +230,15 @@ export class ManagedIndexer implements vscode.Disposable {
 
 		for (const folder of vscode.workspace.workspaceFolders ?? []) {
 			const config = await getKilocodeConfig(folder.uri.fsPath)
-			if (config?.project?.managedIndexingEnabled) {
-				this.enabledViaConfig = true
+			if (config?.project?.managedIndexingEnabled === false) {
+				this.disabledViaConfig = true
 			}
 		}
 
-		this.organization = await this.fetchOrganization()
+		this.enabledViaApi = await isEnabled(kilocodeToken, kilocodeOrganizationId ?? null)
+		console.debug(
+			`[ManagedIndexer] Starting indexer. config disabled: ${this.disabledViaConfig}, API: ${this.enabledViaApi}`,
+		)
 
 		this.sendStateToWebview()
 
@@ -248,13 +246,6 @@ export class ManagedIndexer implements vscode.Disposable {
 			return
 		}
 
-		// TODO: Plumb kilocodeTesterWarningsDisabledUntil through
-		const { kilocodeOrganizationId, kilocodeToken } = this.config ?? {}
-
-		if (!kilocodeToken) {
-			return
-		}
-
 		this.isActive = true
 
 		if (!vscode.workspace.workspaceFolders) {
@@ -297,8 +288,10 @@ export class ManagedIndexer implements vscode.Disposable {
 					const config = await getKilocodeConfig(cwd, repositoryUrl)
 					const projectId = config?.project?.id
 
-					// TODO: (brianc) - only index projects if they're enabled in the config
-					// right now if any workspace folder is enabled, we index them all
+					// if managed indexing is specifically disabled in the config, skip this folder
+					if (config?.project?.managedIndexingEnabled === false) {
+						return null
+					}
 
 					if (!projectId) {
 						console.log("[ManagedIndexer] No project ID found for workspace folder", cwd)
@@ -404,7 +397,6 @@ export class ManagedIndexer implements vscode.Disposable {
 		this.workspaceFolderState = []
 
 		this.isActive = false
-		this.organization = null
 	}
 
 	/**

+ 5 - 97
src/services/code-index/managed/__tests__/ManagedIndexer.spec.ts

@@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
 import * as vscode from "vscode"
 import { ManagedIndexer } from "../ManagedIndexer"
 import { GitWatcher, GitWatcherEvent, GitWatcherFile } from "../../../../shared/GitWatcher"
-import { OrganizationService } from "../../../kilocode/OrganizationService"
 import * as gitUtils from "../git-utils"
 import * as kiloConfigFile from "../../../../utils/kilo-config-file"
 import * as git from "../../../../utils/git"
@@ -34,7 +33,6 @@ vi.mock("vscode", () => ({
 
 // Mock dependencies
 vi.mock("../../../../shared/GitWatcher")
-vi.mock("../../../kilocode/OrganizationService")
 vi.mock("../git-utils")
 vi.mock("../../../../utils/kilo-config-file")
 vi.mock("../../../../utils/git")
@@ -80,6 +78,9 @@ describe("ManagedIndexer", () => {
 				if (key === "kilocodeTesterWarningsDisabledUntil") return null
 				return null
 			}),
+			getGlobalState: vi.fn((key: string) => {
+				return null
+			}),
 			onManagedIndexerConfigChange: vi.fn(() => ({
 				dispose: vi.fn(),
 			})),
@@ -106,12 +107,7 @@ describe("ManagedIndexer", () => {
 			files: {},
 		} as any)
 
-		// Mock OrganizationService
-		vi.mocked(OrganizationService.fetchOrganization).mockResolvedValue({
-			id: "test-org-id",
-			name: "Test Org",
-		} as any)
-		vi.mocked(OrganizationService.isCodeIndexingEnabled).mockReturnValue(true)
+		vi.mocked(apiClient.isEnabled).mockResolvedValue(true)
 
 		// Mock GitWatcher - store instances for later verification
 		const mockWatcherInstances: any[] = []
@@ -192,82 +188,6 @@ describe("ManagedIndexer", () => {
 		})
 	})
 
-	describe("fetchOrganization", () => {
-		it("should fetch organization when token and org ID are present", async () => {
-			const org = await indexer.fetchOrganization()
-
-			expect(OrganizationService.fetchOrganization).toHaveBeenCalledWith("test-token", "test-org-id", undefined)
-			expect(org).toEqual({
-				id: "test-org-id",
-				name: "Test Org",
-			})
-		})
-
-		it("should return null when token is missing", async () => {
-			mockContextProxy.getSecret.mockReturnValue(null)
-
-			const org = await indexer.fetchOrganization()
-
-			expect(OrganizationService.fetchOrganization).not.toHaveBeenCalled()
-			expect(org).toBeNull()
-		})
-
-		it("should return null when org ID is missing", async () => {
-			mockContextProxy.getValue.mockImplementation((key: string) => {
-				if (key === "kilocodeOrganizationId") return null
-				if (key === "kilocodeTesterWarningsDisabledUntil") return null
-				return null
-			})
-
-			const org = await indexer.fetchOrganization()
-
-			expect(OrganizationService.fetchOrganization).not.toHaveBeenCalled()
-			expect(org).toBeNull()
-		})
-
-		it("should store organization in instance", async () => {
-			await indexer.fetchOrganization()
-
-			expect(indexer.organization).toEqual({
-				id: "test-org-id",
-				name: "Test Org",
-			})
-		})
-	})
-
-	describe("isEnabled", () => {
-		it("should return true when organization exists and feature is enabled", async () => {
-			// Must fetch organization first to populate indexer.organization
-			await indexer.fetchOrganization()
-
-			const enabled = indexer.isEnabled()
-
-			expect(enabled).toBe(true)
-		})
-
-		it("should return false when organization does not exist", async () => {
-			vi.mocked(OrganizationService.fetchOrganization).mockResolvedValue(null)
-
-			// Must fetch organization first
-			await indexer.fetchOrganization()
-
-			const enabled = indexer.isEnabled()
-
-			expect(enabled).toBe(false)
-		})
-
-		it("should return false when code indexing is not enabled", async () => {
-			vi.mocked(OrganizationService.isCodeIndexingEnabled).mockReturnValue(false)
-
-			// Must fetch organization first
-			await indexer.fetchOrganization()
-
-			const enabled = indexer.isEnabled()
-
-			expect(enabled).toBe(false)
-		})
-	})
-
 	describe("start", () => {
 		beforeEach(() => {
 			vi.mocked(vscode.workspace).workspaceFolders = [mockWorkspaceFolder]
@@ -283,7 +203,7 @@ describe("ManagedIndexer", () => {
 		})
 
 		it("should not start when feature is not enabled", async () => {
-			vi.mocked(OrganizationService.isCodeIndexingEnabled).mockReturnValue(false)
+			vi.mocked(apiClient.isEnabled).mockReturnValue(Promise.resolve(false))
 
 			await indexer.start()
 
@@ -300,18 +220,6 @@ describe("ManagedIndexer", () => {
 			expect(indexer.workspaceFolderState).toEqual([])
 		})
 
-		it("should not start when organization ID is missing", async () => {
-			mockContextProxy.getValue.mockImplementation((key: string) => {
-				if (key === "kilocodeOrganizationId") return null
-				return null
-			})
-
-			await indexer.start()
-
-			expect(indexer.isActive).toBe(false)
-			expect(indexer.workspaceFolderState).toEqual([])
-		})
-
 		it("should skip non-git repositories", async () => {
 			vi.mocked(gitUtils.isGitRepository).mockResolvedValue(false)
 

+ 29 - 0
src/services/code-index/managed/api-client.ts

@@ -11,6 +11,35 @@ import { logger } from "../../../utils/logging"
 import { getKiloBaseUriFromToken } from "../../../../packages/types/src/kilocode/kilocode"
 import { fetchWithRetries } from "../../../shared/http"
 
+export async function isEnabled(kilocodeToken: string, organizationId: string | null): Promise<boolean> {
+	try {
+		const baseUrl = getKiloBaseUriFromToken(kilocodeToken)
+		let url = `${baseUrl}/api/code-indexing/enabled`
+		if (organizationId) {
+			url += `?${new URLSearchParams({ organizationId }).toString()}`
+		}
+		const response = await fetchWithRetries({
+			url,
+			method: "GET",
+			headers: {
+				Authorization: `Bearer ${kilocodeToken}`,
+				"Content-Type": "application/json",
+			},
+		})
+
+		if (!response.ok) {
+			console.error(`Failed to check if managed indexing is enabled: ${response.statusText}`)
+			return false
+		}
+
+		const result = await response.json()
+		return result.enabled
+	} catch (error) {
+		console.error(`Failed to check if managed indexing is enabled: ${error}`)
+		return false
+	}
+}
+
 /**
  * Searches code in the managed index with branch preferences
  *

+ 291 - 0
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<typeof InlineCompletionArgsSchema>
+
+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<CompletionResult> {
+		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)),
+	)
+}

+ 8 - 3
src/services/ghost/GhostServiceManager.ts

@@ -9,6 +9,8 @@ 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"
+import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry"
 
 export class GhostServiceManager {
 	private readonly model: GhostModel
@@ -38,12 +40,14 @@ export class GhostServiceManager {
 
 		// Register the providers
 		this.codeActionProvider = new GhostCodeActionProvider()
+		const { kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties()
 		this.inlineCompletionProvider = new GhostInlineCompletionProvider(
 			this.context,
 			this.model,
 			this.updateCostTracking.bind(this),
 			() => this.settings,
 			this.cline,
+			!kiloCodeWrapperJetbrains ? new AutocompleteTelemetry() : null,
 		)
 
 		void this.load()
@@ -57,10 +61,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()

+ 359 - 0
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")
+		})
+	})
+})

+ 194 - 0
src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts

@@ -0,0 +1,194 @@
+import * as vscode from "vscode"
+import { GhostModel } from "../GhostModel"
+import { ProviderSettingsManager } from "../../../core/config/ProviderSettingsManager"
+import { VisibleCodeContext } from "../types"
+import { ApiStreamChunk } from "../../../api/transform/stream"
+
+/**
+ * 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<boolean> {
+		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: "" }
+			}
+		}
+
+		// Check if model has valid credentials (but don't require FIM)
+		if (!this.model.hasValidCredentials()) {
+			return { suggestion: "" }
+		}
+
+		const prefix = await this.buildPrefix(userText, visibleCodeContext)
+		const suffix = ""
+
+		let response = ""
+
+		// Use FIM if supported, otherwise fall back to chat-based completion
+		if (this.model.supportsFim()) {
+			await this.model.generateFimResponse(prefix, suffix, (chunk) => {
+				response += chunk
+			})
+		} else {
+			// Fall back to chat-based completion for models without FIM support
+			const systemPrompt = this.getChatSystemPrompt()
+			const userPrompt = this.getChatUserPrompt(prefix)
+
+			await this.model.generateResponse(systemPrompt, userPrompt, (chunk) => {
+				if (chunk.type === "text") {
+					response += chunk.text
+				}
+			})
+		}
+
+		const cleanedSuggestion = this.cleanSuggestion(response, userText)
+
+		return { suggestion: cleanedSuggestion }
+	}
+
+	/**
+	 * Get system prompt for chat-based completion
+	 */
+	private getChatSystemPrompt(): string {
+		return `You are an intelligent chat completion assistant. Your task is to complete the user's message naturally based on the provided context.
+
+## RULES
+- Provide a natural, conversational completion
+- Be concise - typically 1-15 words
+- Match the user's tone and style
+- Use context from visible code if relevant
+- NEVER repeat what the user already typed
+- NEVER start with comments (//, /*, #)
+- Return ONLY the completion text, no explanations or formatting`
+	}
+
+	/**
+	 * Get user prompt for chat-based completion
+	 */
+	private getChatUserPrompt(prefix: string): string {
+		return `${prefix}
+
+TASK: Complete the user's message naturally. Return ONLY the completion text (what comes next), no explanations.`
+	}
+
+	/**
+	 * Build the prefix for FIM completion with visible code context and additional sources
+	 */
+	private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<string> {
+		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<string | null> {
+		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
+	}
+}

+ 90 - 0
src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts

@@ -0,0 +1,90 @@
+import { ChatTextAreaAutocomplete } from "../ChatTextAreaAutocomplete"
+import { ProviderSettingsManager } from "../../../../core/config/ProviderSettingsManager"
+import { GhostModel } from "../../GhostModel"
+import { ApiStreamChunk } from "../../../../api/transform/stream"
+
+describe("ChatTextAreaAutocomplete", () => {
+	let autocomplete: ChatTextAreaAutocomplete
+	let mockProviderSettingsManager: ProviderSettingsManager
+
+	beforeEach(() => {
+		mockProviderSettingsManager = {} as ProviderSettingsManager
+		autocomplete = new ChatTextAreaAutocomplete(mockProviderSettingsManager)
+	})
+
+	describe("getCompletion", () => {
+		it("should work with non-FIM models using chat-based completion", async () => {
+			// Setup: Model without FIM support (like Mistral)
+			const mockModel = new GhostModel()
+			mockModel.loaded = true
+
+			vi.spyOn(mockModel, "hasValidCredentials").mockReturnValue(true)
+			vi.spyOn(mockModel, "supportsFim").mockReturnValue(false)
+			vi.spyOn(mockModel, "generateResponse").mockImplementation(async (systemPrompt, userPrompt, onChunk) => {
+				// Simulate streaming chat response
+				const chunks: ApiStreamChunk[] = [{ type: "text", text: "write a function" }]
+				for (const chunk of chunks) {
+					onChunk(chunk)
+				}
+				return {
+					cost: 0,
+					inputTokens: 15,
+					outputTokens: 8,
+					cacheWriteTokens: 0,
+					cacheReadTokens: 0,
+				}
+			})
+
+			// @ts-expect-error - accessing private property for test
+			autocomplete.model = mockModel
+
+			const result = await autocomplete.getCompletion("How to ")
+
+			expect(mockModel.generateResponse).toHaveBeenCalled()
+			expect(result.suggestion).toBe("write a function")
+		})
+	})
+
+	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)
+		})
+	})
+})

+ 37 - 0
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<void> {
+	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 || "",
+		})
+	}
+}

+ 123 - 119
src/services/ghost/classic-auto-complete/AutocompleteTelemetry.ts

@@ -4,132 +4,136 @@ import type { AutocompleteContext, CacheMatchType } from "../types"
 
 export type { AutocompleteContext, CacheMatchType }
 
-function captureAutocompleteTelemetry(event: TelemetryEventName, properties?: Record<string, unknown>): void {
-	// also log to console:
-	if (TelemetryService.hasInstance()) {
-		if (properties !== undefined) {
-			TelemetryService.instance.captureEvent(event, properties)
-			console.log(`Autocomplete Telemetry event: ${event}`, properties)
-		} else {
-			TelemetryService.instance.captureEvent(event)
-			console.log(`Autocomplete Telemetry event: ${event}`)
+/**
+ * Telemetry service for autocomplete events.
+ * Can be initialized without parameters and injected into components that need telemetry tracking.
+ */
+export class AutocompleteTelemetry {
+	constructor() {}
+
+	private captureEvent(event: TelemetryEventName, properties?: Record<string, unknown>): void {
+		// also log to console:
+		if (TelemetryService.hasInstance()) {
+			if (properties !== undefined) {
+				TelemetryService.instance.captureEvent(event, properties)
+				console.log(`Autocomplete Telemetry event: ${event}`, properties)
+			} else {
+				TelemetryService.instance.captureEvent(event)
+				console.log(`Autocomplete Telemetry event: ${event}`)
+			}
 		}
 	}
-}
 
-/**
- * Capture when a suggestion is requested, this is whenever our completion provider is invoked by VS Code
- *
- * Subsets:
- *  - captureLlmRequestCompleted
- *  - captureLlmRequestFailed
- *  - captureCacheHit
- *  - (not captured) request is not answered, for instance because we are debouncing (i.e. user is still typing)
- */
-export function captureSuggestionRequested(context: AutocompleteContext): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_SUGGESTION_REQUESTED, {
-		languageId: context.languageId,
-		modelId: context.modelId,
-		provider: context.provider,
-	})
-}
+	/**
+	 * Capture when a suggestion is requested, this is whenever our completion provider is invoked by VS Code
+	 *
+	 * Subsets:
+	 *  - captureLlmRequestCompleted
+	 *  - captureLlmRequestFailed
+	 *  - captureCacheHit
+	 *  - (not captured) request is not answered, for instance because we are debouncing (i.e. user is still typing)
+	 */
+	public captureSuggestionRequested(context: AutocompleteContext): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_SUGGESTION_REQUESTED, {
+			languageId: context.languageId,
+			modelId: context.modelId,
+			provider: context.provider,
+		})
+	}
 
-/**
- * Capture when a suggestion is filtered out by our software
- *
- * @param reason - The reason the suggestion was filtered out
- * @param context - The autocomplete context
- */
-export function captureSuggestionFiltered(
-	reason: "empty_response" | "filtered_by_postprocessing",
-	context: AutocompleteContext,
-): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_SUGGESTION_FILTERED, {
-		reason,
-		...context,
-	})
-}
+	/**
+	 * Capture when a suggestion is filtered out by our software
+	 *
+	 * @param reason - The reason the suggestion was filtered out
+	 * @param context - The autocomplete context
+	 */
+	public captureSuggestionFiltered(
+		reason: "empty_response" | "filtered_by_postprocessing",
+		context: AutocompleteContext,
+	): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_SUGGESTION_FILTERED, {
+			reason,
+			...context,
+		})
+	}
 
-/**
- * Capture when a suggestion is found in cache/history
- *
- * @param matchType - How the suggestion was matched from cache
- * @param context - The autocomplete context
- * @param suggestionLength - The length of the suggestion in characters
- */
-export function captureCacheHit(
-	matchType: CacheMatchType,
-	context: AutocompleteContext,
-	suggestionLength: number,
-): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_SUGGESTION_CACHE_HIT, {
-		matchType,
-		languageId: context.languageId,
-		modelId: context.modelId,
-		provider: context.provider,
-		suggestionLength,
-	})
-}
+	/**
+	 * Capture when a suggestion is found in cache/history
+	 *
+	 * @param matchType - How the suggestion was matched from cache
+	 * @param context - The autocomplete context
+	 * @param suggestionLength - The length of the suggestion in characters
+	 */
+	public captureCacheHit(matchType: CacheMatchType, context: AutocompleteContext, suggestionLength: number): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_SUGGESTION_CACHE_HIT, {
+			matchType,
+			languageId: context.languageId,
+			modelId: context.modelId,
+			provider: context.provider,
+			suggestionLength,
+		})
+	}
 
-/**
- * Capture when a newly requested suggestion is returned to the user (so no cache hit)
- *
- * Summed with the cache hits this is the total number of suggestions shown
- *
- * @param context - The autocomplete context
- * @param suggestionLength - The length of the suggestion in characters
- */
-export function captureLlmSuggestionReturned(context: AutocompleteContext, suggestionLength: number): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_LLM_SUGGESTION_RETURNED, {
-		...context,
-		suggestionLength,
-	})
-}
+	/**
+	 * Capture when a newly requested suggestion is returned to the user (so no cache hit)
+	 *
+	 * Summed with the cache hits this is the total number of suggestions shown
+	 *
+	 * @param context - The autocomplete context
+	 * @param suggestionLength - The length of the suggestion in characters
+	 */
+	public captureLlmSuggestionReturned(context: AutocompleteContext, suggestionLength: number): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_LLM_SUGGESTION_RETURNED, {
+			...context,
+			suggestionLength,
+		})
+	}
 
-/**
- * Capture when an LLM request completes successfully
- *
- * @param properties - Request metrics including latency, cost, and token counts
- * @param context - The autocomplete context
- */
-export function captureLlmRequestCompleted(
-	properties: {
-		latencyMs: number
-		cost: number
-		inputTokens: number
-		outputTokens: number
-	},
-	context: AutocompleteContext,
-): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_COMPLETED, {
-		...properties,
-		...context,
-	})
-}
+	/**
+	 * Capture when an LLM request completes successfully
+	 *
+	 * @param properties - Request metrics including latency, cost, and token counts
+	 * @param context - The autocomplete context
+	 */
+	public captureLlmRequestCompleted(
+		properties: {
+			latencyMs: number
+			cost: number
+			inputTokens: number
+			outputTokens: number
+		},
+		context: AutocompleteContext,
+	): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_COMPLETED, {
+			...properties,
+			...context,
+		})
+	}
 
-/**
- * Capture when an LLM request fails
- *
- * @param properties - Error details including latency and error message
- * @param context - The autocomplete context
- */
-export function captureLlmRequestFailed(
-	properties: { latencyMs: number; error: string },
-	context: AutocompleteContext,
-): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_FAILED, {
-		...properties,
-		...context,
-	})
-}
+	/**
+	 * Capture when an LLM request fails
+	 *
+	 * @param properties - Error details including latency and error message
+	 * @param context - The autocomplete context
+	 */
+	public captureLlmRequestFailed(
+		properties: { latencyMs: number; error: string },
+		context: AutocompleteContext,
+	): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_FAILED, {
+			...properties,
+			...context,
+		})
+	}
 
-/**
- * Capture when a user accepts a suggestion
- *
- * There are two ways to analyze what percentage was accepted:
- * 1. Sum of this event divided by the sum of the suggestion returned event
- * 2. Sum of this event divided by the sum of the suggestion returned + cache hit events
- */
-export function captureAcceptSuggestion(): void {
-	captureAutocompleteTelemetry(TelemetryEventName.AUTOCOMPLETE_ACCEPT_SUGGESTION)
+	/**
+	 * Capture when a user accepts a suggestion
+	 *
+	 * There are two ways to analyze what percentage was accepted:
+	 * 1. Sum of this event divided by the sum of the suggestion returned event
+	 * 2. Sum of this event divided by the sum of the suggestion returned + cache hit events
+	 */
+	public captureAcceptSuggestion(): void {
+		this.captureEvent(TelemetryEventName.AUTOCOMPLETE_ACCEPT_SUGGESTION)
+	}
 }

+ 34 - 13
src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts

@@ -23,10 +23,17 @@ import type { GhostServiceSettings } from "@roo-code/types"
 import { postprocessGhostSuggestion } from "./uselessSuggestionFilter"
 import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
 import { ClineProvider } from "../../../core/webview/ClineProvider"
-import * as telemetry from "./AutocompleteTelemetry"
+import { AutocompleteTelemetry } from "./AutocompleteTelemetry"
 
 const MAX_SUGGESTIONS_HISTORY = 20
 
+/**
+ * Minimum debounce delay in milliseconds.
+ * The adaptive debounce delay will never go below this value, even when
+ * average latencies are very fast.
+ */
+const MIN_DEBOUNCE_DELAY_MS = 150
+
 /**
  * Initial debounce delay in milliseconds.
  * This value is used as the starting debounce delay before enough latency samples
@@ -35,6 +42,13 @@ const MAX_SUGGESTIONS_HISTORY = 20
  */
 const INITIAL_DEBOUNCE_DELAY_MS = 300
 
+/**
+ * Maximum debounce delay in milliseconds.
+ * This caps the adaptive debounce delay to prevent excessive waiting times
+ * even when latencies are high.
+ */
+const MAX_DEBOUNCE_DELAY_MS = 1000
+
 /**
  * Number of latency samples to collect before using adaptive debounce delay.
  * Once this many samples are collected, the debounce delay becomes the average
@@ -123,8 +137,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
@@ -136,6 +150,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 	private acceptedCommand: vscode.Disposable | null = null
 	private debounceDelayMs: number = INITIAL_DEBOUNCE_DELAY_MS
 	private latencyHistory: number[] = []
+	private telemetry: AutocompleteTelemetry | null
 
 	constructor(
 		context: vscode.ExtensionContext,
@@ -143,7 +158,9 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 		costTrackingCallback: CostTrackingCallback,
 		getSettings: () => GhostServiceSettings | null,
 		cline: ClineProvider,
+		telemetry: AutocompleteTelemetry | null = null,
 	) {
+		this.telemetry = telemetry
 		this.model = model
 		this.costTrackingCallback = costTrackingCallback
 		this.getSettings = getSettings
@@ -170,7 +187,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 		this.recentlyEditedTracker = new RecentlyEditedTracker(ide)
 
 		this.acceptedCommand = vscode.commands.registerCommand(INLINE_COMPLETION_ACCEPTED_COMMAND, () =>
-			telemetry.captureAcceptSuggestion(),
+			this.telemetry?.captureAcceptSuggestion(),
 		)
 	}
 
@@ -231,7 +248,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 		telemetryContext: AutocompleteContext,
 	): FillInAtCursorSuggestion {
 		if (!suggestionText) {
-			telemetry.captureSuggestionFiltered("empty_response", telemetryContext)
+			this.telemetry?.captureSuggestionFiltered("empty_response", telemetryContext)
 			return { text: "", prefix, suffix }
 		}
 
@@ -246,7 +263,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 			return { text: processedText, prefix, suffix }
 		}
 
-		telemetry.captureSuggestionFiltered("filtered_by_postprocessing", telemetryContext)
+		this.telemetry?.captureSuggestionFiltered("filtered_by_postprocessing", telemetryContext)
 		return { text: "", prefix, suffix }
 	}
 
@@ -262,7 +279,8 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 	 * Records a latency measurement and updates the adaptive debounce delay.
 	 * Maintains a rolling window of the last LATENCY_SAMPLE_SIZE latencies.
 	 * Once enough samples are collected, the debounce delay is set to the
-	 * average of all stored latencies.
+	 * average of all stored latencies, clamped between MIN_DEBOUNCE_DELAY_MS
+	 * and MAX_DEBOUNCE_DELAY_MS.
 	 *
 	 * @param latencyMs - The latency of the most recent request in milliseconds
 	 */
@@ -276,7 +294,10 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 
 			// Once we have enough samples, update the debounce delay to the average
 			const sum = this.latencyHistory.reduce((acc, val) => acc + val, 0)
-			this.debounceDelayMs = Math.round(sum / this.latencyHistory.length)
+			const averageLatency = Math.round(sum / this.latencyHistory.length)
+
+			// Clamp the debounce delay between MIN and MAX
+			this.debounceDelayMs = Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(averageLatency, MAX_DEBOUNCE_DELAY_MS))
 		}
 	}
 
@@ -323,7 +344,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 			provider: this.model?.getProviderDisplayName(),
 		}
 
-		telemetry.captureSuggestionRequested(telemetryContext)
+		this.telemetry?.captureSuggestionRequested(telemetryContext)
 
 		if (!this.model || !this.model.hasValidCredentials()) {
 			// bail if no model is available or no valid API credentials configured
@@ -367,7 +388,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 			const matchingResult = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory)
 
 			if (matchingResult !== null) {
-				telemetry.captureCacheHit(matchingResult.matchType, telemetryContext, matchingResult.text.length)
+				this.telemetry?.captureCacheHit(matchingResult.matchType, telemetryContext, matchingResult.text.length)
 				return stringToInlineCompletions(matchingResult.text, position)
 			}
 
@@ -380,7 +401,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 
 			const cachedResult = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory)
 			if (cachedResult) {
-				telemetry.captureLlmSuggestionReturned(telemetryContext, cachedResult.text.length)
+				this.telemetry?.captureLlmSuggestionReturned(telemetryContext, cachedResult.text.length)
 			}
 
 			return stringToInlineCompletions(cachedResult?.text ?? "", position)
@@ -512,7 +533,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 
 			const latencyMs = performance.now() - startTime
 
-			telemetry.captureLlmRequestCompleted(
+			this.telemetry?.captureLlmRequestCompleted(
 				{
 					latencyMs,
 					cost: result.cost,
@@ -531,7 +552,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
 			this.updateSuggestions(result.suggestion)
 		} catch (error) {
 			const latencyMs = performance.now() - startTime
-			telemetry.captureLlmRequestFailed(
+			this.telemetry?.captureLlmRequestFailed(
 				{
 					latencyMs,
 					error: error instanceof Error ? error.message : String(error),

+ 47 - 16
src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts

@@ -8,7 +8,7 @@ import {
 import { FillInAtCursorSuggestion } from "../HoleFiller"
 import { MockTextDocument } from "../../../mocking/MockTextDocument"
 import { GhostModel } from "../../GhostModel"
-import * as telemetry from "../AutocompleteTelemetry"
+import { AutocompleteTelemetry } from "../AutocompleteTelemetry"
 import * as GhostContextProviderModule from "../getProcessedSnippets"
 
 // Mock RooIgnoreController to prevent vscode.RelativePattern errors
@@ -22,16 +22,8 @@ vi.mock("../../../../core/ignore/RooIgnoreController", () => {
 	}
 })
 
-// Mock AutocompleteTelemetry module
-vi.mock("../AutocompleteTelemetry", () => ({
-	captureSuggestionRequested: vi.fn(),
-	captureSuggestionFiltered: vi.fn(),
-	captureCacheHit: vi.fn(),
-	captureLlmSuggestionReturned: vi.fn(),
-	captureLlmRequestCompleted: vi.fn(),
-	captureLlmRequestFailed: vi.fn(),
-	captureAcceptSuggestion: vi.fn(),
-}))
+// Mock AutocompleteTelemetry class - don't mock it, let it be created normally
+// The tests will create real instances or null as needed
 
 // Mock vscode InlineCompletionTriggerKind enum and event listeners
 vi.mock("vscode", async () => {
@@ -523,6 +515,7 @@ describe("GhostInlineCompletionProvider", () => {
 	let mockSettings: { enableAutoTrigger: boolean } | null
 	let mockExtensionContext: vscode.ExtensionContext
 	let mockClineProvider: { cwd: string }
+	let mockTelemetry: AutocompleteTelemetry
 
 	// Helper to call provideInlineCompletionItems and advance timers
 	// With leading edge debounce, first call executes immediately, subsequent calls wait for 300ms of inactivity
@@ -611,6 +604,7 @@ describe("GhostInlineCompletionProvider", () => {
 		} as unknown as GhostModel
 		mockCostTrackingCallback = vi.fn() as CostTrackingCallback
 		mockClineProvider = { cwd: "/test/workspace" }
+		mockTelemetry = new AutocompleteTelemetry()
 
 		provider = new GhostInlineCompletionProvider(
 			mockExtensionContext,
@@ -618,6 +612,7 @@ describe("GhostInlineCompletionProvider", () => {
 			mockCostTrackingCallback,
 			() => mockSettings,
 			mockClineProvider as any,
+			mockTelemetry,
 		)
 	})
 
@@ -2128,10 +2123,6 @@ describe("GhostInlineCompletionProvider", () => {
 	})
 
 	describe("telemetry tracking", () => {
-		beforeEach(() => {
-			vi.mocked(telemetry.captureAcceptSuggestion).mockClear()
-		})
-
 		it("should track acceptance when suggestion is accepted via command", async () => {
 			// Capture the registered command callback by setting up mock before provider creation
 			let acceptCallback: (() => void) | undefined
@@ -2143,6 +2134,10 @@ describe("GhostInlineCompletionProvider", () => {
 				return { dispose: vi.fn() }
 			})
 
+			// Create new telemetry instance for this test
+			const testTelemetry = new AutocompleteTelemetry()
+			vi.spyOn(testTelemetry, "captureAcceptSuggestion")
+
 			// Create new provider to capture the command
 			const testProvider = new GhostInlineCompletionProvider(
 				mockExtensionContext,
@@ -2150,6 +2145,7 @@ describe("GhostInlineCompletionProvider", () => {
 				mockCostTrackingCallback,
 				() => mockSettings,
 				mockClineProvider as any,
+				testTelemetry,
 			)
 
 			// Verify callback was captured
@@ -2178,7 +2174,42 @@ describe("GhostInlineCompletionProvider", () => {
 			// Simulate accepting the suggestion
 			acceptCallback!()
 
-			expect(telemetry.captureAcceptSuggestion).toHaveBeenCalled()
+			expect(testTelemetry.captureAcceptSuggestion).toHaveBeenCalled()
+
+			// Cleanup
+			testProvider.dispose()
+		})
+
+		it("should work without telemetry when null is passed", async () => {
+			// Create provider without telemetry
+			const testProvider = new GhostInlineCompletionProvider(
+				mockExtensionContext,
+				mockModel,
+				mockCostTrackingCallback,
+				() => mockSettings,
+				mockClineProvider as any,
+				null,
+			)
+
+			// Set up a suggestion
+			testProvider.updateSuggestions({
+				text: "console.log('test');",
+				prefix: "const x = 1",
+				suffix: "\nconst y = 2",
+			})
+
+			// Should work without errors
+			const promise = testProvider.provideInlineCompletionItems(
+				mockDocument,
+				mockPosition,
+				mockContext,
+				mockToken,
+			)
+			await vi.advanceTimersByTimeAsync(300)
+			const result = await promise
+
+			// Should still return suggestions
+			expect(Array.isArray(result) ? result.length : 0).toBeGreaterThan(0)
 
 			// Cleanup
 			testProvider.dispose()

+ 154 - 0
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<VisibleCodeContext> {
+		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
+	}
+}

+ 235 - 0
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")
+		})
+	})
+})

+ 4 - 0
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 () => {

+ 86 - 0
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<void>
 }
 
+// ============================================================================
+// 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,

+ 195 - 0
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<DeviceAuthServiceEvents> {
+	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<DeviceAuthInitiateResponse> {
+		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<void> {
+		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()
+	}
+}

+ 302 - 0
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: "[email protected]",
+			}
+
+			// 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", "[email protected]")
+		})
+
+		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()
+		})
+	})
+})

+ 15 - 1
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<

+ 7 - 1
src/shared/WebviewMessage.ts

@@ -269,14 +269,20 @@ export interface WebviewMessage {
 		| "shareTaskSession" // kilocode_change
 		| "sessionFork" // kilocode_change
 		| "sessionShow" // kilocode_change
+		| "sessionSelect" // kilocode_change
 		| "singleCompletion" // kilocode_change
 		| "openDebugApiHistory"
 		| "openDebugUiHistory"
+		| "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
+	sessionId?: string // kilocode_change - for sessionSelect
 	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

+ 7 - 0
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)
+}

+ 330 - 279
src/shared/kilocode/cli-sessions/core/SessionManager.ts

@@ -47,12 +47,13 @@ export class SessionManager {
 	static readonly SYNC_INTERVAL = 3000
 	static readonly MAX_PATCH_SIZE_BYTES = 5 * 1024 * 1024
 	static readonly VERSION = 1
+	static readonly QUEUE_FLUSH_THRESHOLD = 5
 
 	private static instance = new SessionManager()
 
 	static init(dependencies?: SessionManagerDependencies) {
 		if (dependencies) {
-			SessionManager.instance.initSingleton(dependencies)
+			SessionManager.instance.initDeps(dependencies)
 		}
 
 		return SessionManager.instance
@@ -64,15 +65,13 @@ export class SessionManager {
 	private sessionTitles: Record<string, string> = {}
 	private sessionUpdatedAt: Record<string, string> = {}
 	private tokenValid: Record<string, boolean | undefined> = {}
+	private verifiedSessions: Set<string> = new Set()
 
 	public get sessionId() {
 		return this.lastActiveSessionId || this.sessionPersistenceManager?.getLastSession()?.sessionId
 	}
 	private lastActiveSessionId: string | null = null
 
-	private timer: NodeJS.Timeout | null = null
-	private isSyncing: boolean = false
-
 	private pathProvider: IPathProvider | undefined
 	private logger: ILogger | undefined
 	private extensionMessenger: IExtensionMessenger | undefined
@@ -108,24 +107,6 @@ export class SessionManager {
 
 	private pendingSync: Promise<void> | null = null
 
-	private initSingleton(dependencies: SessionManagerDependencies) {
-		this.initDeps(dependencies)
-
-		if (!this.timer) {
-			this.timer = setInterval(async () => {
-				if (this.pendingSync) {
-					return
-				}
-
-				this.pendingSync = this.syncSession()
-
-				await this.pendingSync
-
-				this.pendingSync = null
-			}, SessionManager.SYNC_INTERVAL)
-		}
-	}
-
 	private queue = [] as {
 		taskId: string
 		blobName: string
@@ -144,6 +125,10 @@ export class SessionManager {
 				timestamp: Date.now(),
 			})
 		}
+
+		if (this.queue.length > SessionManager.QUEUE_FLUSH_THRESHOLD) {
+			this.doSync()
+		}
 	}
 
 	setWorkspaceDirectory(dir: string) {
@@ -196,8 +181,6 @@ export class SessionManager {
 				throw new Error("SessionManager used before initialization")
 			}
 
-			this.isSyncing = true
-
 			const session = (await this.sessionClient.get({
 				session_id: sessionId,
 				include_blob_urls: true,
@@ -301,6 +284,7 @@ export class SessionManager {
 
 			this.sessionPersistenceManager.setSessionForTask(historyItem.id, sessionId)
 			this.lastActiveSessionId = sessionId
+			this.verifiedSessions.add(sessionId)
 
 			await this.extensionMessenger.sendWebviewMessage({
 				type: "addTaskToHistory",
@@ -333,8 +317,6 @@ export class SessionManager {
 			if (rethrowError) {
 				throw error
 			}
-		} finally {
-			this.isSyncing = false
 		}
 	}
 
@@ -404,6 +386,39 @@ export class SessionManager {
 
 			let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId)
 
+			if (sessionId) {
+				if (!this.verifiedSessions.has(sessionId)) {
+					this.logger?.debug("Verifying session existence", "SessionManager", { taskId, sessionId })
+
+					try {
+						const session = await this.sessionClient.get({
+							session_id: sessionId,
+							include_blob_urls: false,
+						})
+
+						if (!session) {
+							this.logger?.info("Session no longer exists, will create new session", "SessionManager", {
+								taskId,
+								sessionId,
+							})
+							sessionId = undefined
+						} else {
+							this.verifiedSessions.add(sessionId)
+							this.logger?.debug("Session verified and cached", "SessionManager", { taskId, sessionId })
+						}
+					} catch (error) {
+						this.logger?.info("Session verification failed, will create new session", "SessionManager", {
+							taskId,
+							sessionId,
+							error: error instanceof Error ? error.message : String(error),
+						})
+						sessionId = undefined
+					}
+				} else {
+					this.logger?.debug("Session already verified (cached)", "SessionManager", { taskId, sessionId })
+				}
+			}
+
 			if (!sessionId) {
 				this.logger?.debug("No existing session for task, creating new session", "SessionManager", { taskId })
 
@@ -431,6 +446,8 @@ export class SessionManager {
 				this.logger?.debug("Uploaded conversation blobs to session", "SessionManager", { sessionId })
 
 				this.sessionPersistenceManager.setSessionForTask(taskId, sessionId)
+
+				this.verifiedSessions.add(sessionId)
 			} else {
 				this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId })
 			}
@@ -446,11 +463,6 @@ export class SessionManager {
 	}
 
 	private async syncSession() {
-		if (this.isSyncing) {
-			this.logger?.debug("Sync already in progress, skipping", "SessionManager")
-			return
-		}
-
 		if (this.queue.length === 0) {
 			return
 		}
@@ -466,336 +478,379 @@ export class SessionManager {
 			return
 		}
 
-		try {
-			this.isSyncing = true
-
-			const token = await this.getToken?.()
+		const token = await this.getToken?.()
 
-			if (!token) {
-				this.logger?.debug("No token available for session sync, skipping", "SessionManager")
-				return
-			}
+		if (!token) {
+			this.logger?.debug("No token available for session sync, skipping", "SessionManager")
+			return
+		}
 
-			if (this.tokenValid[token] === undefined) {
-				this.logger?.debug("Checking token validity", "SessionManager")
+		if (this.tokenValid[token] === undefined) {
+			this.logger?.debug("Checking token validity", "SessionManager")
 
+			try {
 				const tokenValid = await this.sessionClient.tokenValid()
 
 				this.tokenValid[token] = tokenValid
-
-				this.logger?.debug("Token validity checked", "SessionManager", { tokenValid })
-			}
-
-			if (!this.tokenValid[token]) {
-				this.logger?.debug("Token is invalid, skipping sync", "SessionManager")
+			} catch (error) {
+				this.logger?.error("Failed to check token validity", "SessionManager", {
+					error: error instanceof Error ? error.message : String(error),
+				})
 				return
 			}
 
-			const taskIds = new Set<string>(this.queue.map((item) => item.taskId))
-			const lastItem = this.queue[this.queue.length - 1]
+			this.logger?.debug("Token validity checked", "SessionManager", { tokenValid: this.tokenValid[token] })
+		}
+
+		if (!this.tokenValid[token]) {
+			this.logger?.debug("Token is invalid, skipping sync", "SessionManager")
+			return
+		}
 
-			this.logger?.debug("Starting session sync", "SessionManager", {
-				queueLength: this.queue.length,
-				taskCount: taskIds.size,
+		const taskIds = new Set<string>(this.queue.map((item) => item.taskId))
+		const lastItem = this.queue[this.queue.length - 1]
+
+		this.logger?.debug("Starting session sync", "SessionManager", {
+			queueLength: this.queue.length,
+			taskCount: taskIds.size,
+		})
+
+		let gitInfo: Awaited<ReturnType<typeof this.getGitState>> | null = null
+		try {
+			gitInfo = await this.getGitState()
+		} catch (error) {
+			this.logger?.debug("Could not get git state", "SessionManager", {
+				error: error instanceof Error ? error.message : String(error),
 			})
+		}
 
-			let gitInfo: Awaited<ReturnType<typeof this.getGitState>> | null = null
+		for (const taskId of taskIds) {
 			try {
-				gitInfo = await this.getGitState()
-			} catch (error) {
-				this.logger?.debug("Could not get git state", "SessionManager", {
-					error: error instanceof Error ? error.message : String(error),
+				const taskItems = this.queue.filter((item) => item.taskId === taskId)
+				const reversedTaskItems = [...taskItems].reverse()
+
+				this.logger?.debug("Processing task", "SessionManager", {
+					taskId,
+					itemCount: taskItems.length,
 				})
-			}
 
-			for (const taskId of taskIds) {
-				try {
-					const taskItems = this.queue.filter((item) => item.taskId === taskId)
-					const reversedTaskItems = [...taskItems].reverse()
+				const basePayload: Partial<Parameters<NonNullable<typeof this.sessionClient>["create"]>[0]> = {}
 
-					this.logger?.debug("Processing task", "SessionManager", {
-						taskId,
-						itemCount: taskItems.length,
-					})
+				if (gitInfo?.repoUrl) {
+					basePayload.git_url = gitInfo.repoUrl
+				}
 
-					const basePayload: Partial<Parameters<NonNullable<typeof this.sessionClient>["create"]>[0]> = {}
+				let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId)
 
-					if (gitInfo?.repoUrl) {
-						basePayload.git_url = gitInfo.repoUrl
-					}
+				if (sessionId) {
+					this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId })
 
-					let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId)
+					const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId]
 
-					if (sessionId) {
-						this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId })
+					if (gitUrlChanged && gitInfo?.repoUrl) {
+						this.taskGitUrls[taskId] = gitInfo.repoUrl
 
-						const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId]
+						this.logger?.debug("Git URL changed, updating session", "SessionManager", {
+							sessionId,
+							newGitUrl: gitInfo.repoUrl,
+						})
 
-						if (gitUrlChanged && gitInfo?.repoUrl) {
-							this.taskGitUrls[taskId] = gitInfo.repoUrl
+						const updateResult = await this.sessionClient.update({
+							session_id: sessionId,
+							...basePayload,
+						})
 
-							this.logger?.debug("Git URL changed, updating session", "SessionManager", {
-								sessionId,
-								newGitUrl: gitInfo.repoUrl,
-							})
+						this.updateSessionTimestamp(sessionId, updateResult.updated_at)
+					}
+				} else {
+					this.logger?.debug("Creating new session for task", "SessionManager", { taskId })
 
-							const updateResult = await this.sessionClient.update({
-								session_id: sessionId,
-								...basePayload,
-							})
+					const createdSession = await this.sessionClient.create({
+						...basePayload,
+						created_on_platform: this.platform,
+						version: SessionManager.VERSION,
+					})
 
-							this.updateSessionTimestamp(sessionId, updateResult.updated_at)
-						}
-					} else {
-						this.logger?.debug("Creating new session for task", "SessionManager", { taskId })
+					sessionId = createdSession.session_id
 
-						const createdSession = await this.sessionClient.create({
-							...basePayload,
-							created_on_platform: this.platform,
-							version: SessionManager.VERSION,
-						})
+					this.logger?.info("Created new session", "SessionManager", { taskId, sessionId })
 
-						sessionId = createdSession.session_id
+					this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id)
 
-						this.logger?.info("Created new session", "SessionManager", { taskId, sessionId })
+					this.onSessionCreated?.({
+						timestamp: Date.now(),
+						event: "session_created",
+						sessionId: createdSession.session_id,
+					})
+				}
 
-						this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id)
+				if (!sessionId) {
+					this.logger?.warn("No session ID available after create/get, skipping task", "SessionManager", {
+						taskId,
+					})
+					continue
+				}
 
-						this.onSessionCreated?.({
-							timestamp: Date.now(),
-							event: "session_created",
-							sessionId: createdSession.session_id,
-						})
-					}
+				const blobNames = new Set(taskItems.map((item) => item.blobName))
+				const blobUploads: Promise<unknown>[] = []
 
-					if (!sessionId) {
-						this.logger?.warn("No session ID available after create/get, skipping task", "SessionManager", {
+				this.logger?.debug("Uploading blobs for session", "SessionManager", {
+					sessionId,
+					blobNames: Array.from(blobNames),
+				})
+
+				for (const blobName of blobNames) {
+					const lastBlobItem = reversedTaskItems.find((item) => item.blobName === blobName)
+
+					if (!lastBlobItem) {
+						this.logger?.warn("Could not find blob item in reversed list", "SessionManager", {
+							blobName,
 							taskId,
 						})
 						continue
 					}
 
-					const blobNames = new Set(taskItems.map((item) => item.blobName))
-					const blobUploads: Promise<unknown>[] = []
+					const fileContents = JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8"))
 
-					this.logger?.debug("Uploading blobs for session", "SessionManager", {
-						sessionId,
-						blobNames: Array.from(blobNames),
-					})
+					blobUploads.push(
+						this.sessionClient
+							.uploadBlob(
+								sessionId,
+								lastBlobItem.blobName as Parameters<typeof this.sessionClient.uploadBlob>[1],
+								fileContents,
+							)
+							.then((result) => {
+								this.logger?.debug("Blob uploaded successfully", "SessionManager", {
+									sessionId,
+									blobName,
+								})
 
-					for (const blobName of blobNames) {
-						const lastBlobItem = reversedTaskItems.find((item) => item.blobName === blobName)
+								// Track the updated_at timestamp from the upload using high-water mark
+								this.updateSessionTimestamp(sessionId, result.updated_at)
 
-						if (!lastBlobItem) {
-							this.logger?.warn("Could not find blob item in reversed list", "SessionManager", {
-								blobName,
-								taskId,
-							})
-							continue
-						}
+								for (let i = 0; i < this.queue.length; i++) {
+									const item = this.queue[i]
 
-						const fileContents = JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8"))
+									if (!item) {
+										continue
+									}
 
-						blobUploads.push(
-							this.sessionClient
-								.uploadBlob(
+									if (
+										item.blobName === blobName &&
+										item.taskId === taskId &&
+										item.timestamp <= lastBlobItem.timestamp
+									) {
+										this.queue.splice(i, 1)
+										i--
+									}
+								}
+							})
+							.catch((error) => {
+								this.logger?.error("Failed to upload blob", "SessionManager", {
 									sessionId,
-									lastBlobItem.blobName as Parameters<typeof this.sessionClient.uploadBlob>[1],
-									fileContents,
-								)
-								.then((result) => {
-									this.logger?.debug("Blob uploaded successfully", "SessionManager", {
-										sessionId,
-										blobName,
-									})
+									blobName,
+									error: error instanceof Error ? error.message : String(error),
+								})
+							}),
+					)
 
-									// Track the updated_at timestamp from the upload using high-water mark
-									this.updateSessionTimestamp(sessionId, result.updated_at)
+					if (blobName !== "ui_messages" || this.sessionTitles[sessionId]) {
+						continue
+					}
 
-									for (let i = 0; i < this.queue.length; i++) {
-										const item = this.queue[i]
-
-										if (!item) {
-											continue
-										}
-
-										if (
-											item.blobName === blobName &&
-											item.taskId === taskId &&
-											item.timestamp <= lastBlobItem.timestamp
-										) {
-											this.queue.splice(i, 1)
-											i--
-										}
-									}
+					this.logger?.debug("Checking for session title generation", "SessionManager", { sessionId })
+
+					void (async () => {
+						try {
+							if (!this.sessionClient) {
+								this.logger?.warn("Session client not initialized", "SessionManager", {
+									sessionId,
 								})
-								.catch((error) => {
-									this.logger?.error("Failed to upload blob", "SessionManager", {
-										sessionId,
-										blobName,
-										error: error instanceof Error ? error.message : String(error),
-									})
-								}),
-						)
+								return
+							}
 
-						if (blobName === "ui_messages" && !this.sessionTitles[sessionId]) {
-							this.logger?.debug("Checking for session title generation", "SessionManager", { sessionId })
+							this.sessionTitles[sessionId] = "Pending title"
 
-							void (async () => {
-								try {
-									if (!this.sessionClient) {
-										this.logger?.warn("Session client not initialized", "SessionManager", {
-											sessionId,
-										})
-										return
-									}
+							const session = await this.sessionClient.get({ session_id: sessionId })
+
+							if (session.title) {
+								this.sessionTitles[sessionId] = session.title
 
-									this.sessionTitles[sessionId] = "Pending title"
+								this.logger?.debug("Found existing session title", "SessionManager", {
+									sessionId,
+									title: session.title,
+								})
 
-									const session = await this.sessionClient.get({ session_id: sessionId })
+								return
+							}
 
-									if (session.title) {
-										this.sessionTitles[sessionId] = session.title
+							const generatedTitle = await this.generateTitle(fileContents)
 
-										this.logger?.debug("Found existing session title", "SessionManager", {
-											sessionId,
-											title: session.title,
-										})
+							if (!generatedTitle) {
+								throw new Error("Failed to generate session title")
+							}
 
-										return
-									}
+							const updateResult = await this.sessionClient.update({
+								session_id: sessionId,
+								title: generatedTitle,
+							})
 
-									const generatedTitle = await this.generateTitle(fileContents)
+							this.sessionTitles[sessionId] = generatedTitle
+							this.updateSessionTimestamp(sessionId, updateResult.updated_at)
 
-									if (!generatedTitle) {
-										throw new Error("Failed to generate session title")
-									}
+							this.logger?.debug("Updated session title", "SessionManager", {
+								sessionId,
+								generatedTitle,
+							})
+						} catch (error) {
+							this.logger?.error("Failed to generate session title", "SessionManager", {
+								sessionId,
+								error: error instanceof Error ? error.message : String(error),
+							})
 
-									const updateResult = await this.sessionClient.update({
-										session_id: sessionId,
-										title: generatedTitle,
-									})
+							const localTitle = this.getFirstMessageText(fileContents as ClineMessage[], true) || ""
 
-									this.sessionTitles[sessionId] = generatedTitle
-									this.updateSessionTimestamp(sessionId, updateResult.updated_at)
+							if (!localTitle) {
+								return
+							}
 
-									this.logger?.debug("Updated session title", "SessionManager", {
-										sessionId,
-										generatedTitle,
-									})
-								} catch (error) {
-									this.logger?.error("Failed to generate session title", "SessionManager", {
+							try {
+								await this.renameSession(sessionId, localTitle)
+							} catch (error) {
+								this.logger?.error(
+									"Failed to update session title using local title",
+									"SessionManager",
+									{
 										sessionId,
 										error: error instanceof Error ? error.message : String(error),
-									})
-
-									this.sessionTitles[sessionId] = ""
-								}
-							})()
+									},
+								)
+							}
 						}
-					}
+					})()
+				}
 
-					if (gitInfo) {
-						const gitStateData = {
-							head: gitInfo.head,
-							patch: gitInfo.patch,
-							branch: gitInfo.branch,
-						}
+				if (gitInfo) {
+					const gitStateData = {
+						head: gitInfo.head,
+						patch: gitInfo.patch,
+						branch: gitInfo.branch,
+					}
 
-						const gitStateHash = this.hashGitState(gitStateData)
+					const gitStateHash = this.hashGitState(gitStateData)
 
-						if (gitStateHash !== this.taskGitHashes[taskId]) {
-							this.logger?.debug("Git state changed, uploading", "SessionManager", {
-								sessionId,
-								head: gitInfo.head?.substring(0, 8),
-							})
+					if (gitStateHash === this.taskGitHashes[taskId]) {
+						this.logger?.debug("Git state unchanged, skipping upload", "SessionManager", { sessionId })
+					} else {
+						this.logger?.debug("Git state changed, uploading", "SessionManager", {
+							sessionId,
+							head: gitInfo.head?.substring(0, 8),
+						})
 
-							this.taskGitHashes[taskId] = gitStateHash
+						this.taskGitHashes[taskId] = gitStateHash
 
-							blobUploads.push(
-								this.sessionClient
-									.uploadBlob(sessionId, "git_state", gitStateData)
-									.then((result) => {
-										// Track the updated_at timestamp from git state upload using high-water mark
-										this.updateSessionTimestamp(sessionId, result.updated_at)
+						blobUploads.push(
+							this.sessionClient
+								.uploadBlob(sessionId, "git_state", gitStateData)
+								.then((result) => {
+									// Track the updated_at timestamp from git state upload using high-water mark
+									this.updateSessionTimestamp(sessionId, result.updated_at)
+								})
+								.catch((error) => {
+									this.logger?.error("Failed to upload git state", "SessionManager", {
+										sessionId,
+										error: error instanceof Error ? error.message : String(error),
 									})
-									.catch((error) => {
-										this.logger?.error("Failed to upload git state", "SessionManager", {
-											sessionId,
-											error: error instanceof Error ? error.message : String(error),
-										})
-									}),
-							)
-						} else {
-							this.logger?.debug("Git state unchanged, skipping upload", "SessionManager", { sessionId })
-						}
+								}),
+						)
 					}
+				}
 
-					await Promise.all(blobUploads)
+				await Promise.all(blobUploads)
 
-					this.logger?.debug("Completed blob uploads for task", "SessionManager", {
-						taskId,
+				this.logger?.debug("Completed blob uploads for task", "SessionManager", {
+					taskId,
+					sessionId,
+					uploadCount: blobUploads.length,
+				})
+
+				// Emit session synced event with the latest updated_at timestamp
+				const latestUpdatedAt = this.sessionUpdatedAt[sessionId]
+				if (latestUpdatedAt) {
+					const updatedAtTimestamp = new Date(latestUpdatedAt).getTime()
+					this.onSessionSynced?.({
 						sessionId,
-						uploadCount: blobUploads.length,
+						updatedAt: updatedAtTimestamp,
+						timestamp: Date.now(),
+						event: "session_synced",
 					})
 
-					// Emit session synced event with the latest updated_at timestamp
-					const latestUpdatedAt = this.sessionUpdatedAt[sessionId]
-					if (latestUpdatedAt) {
-						const updatedAtTimestamp = new Date(latestUpdatedAt).getTime()
-						this.onSessionSynced?.({
-							sessionId,
-							updatedAt: updatedAtTimestamp,
-							timestamp: Date.now(),
-							event: "session_synced",
-						})
-
-						this.logger?.debug("Emitted session_synced event", "SessionManager", {
-							sessionId,
-							updatedAt: updatedAtTimestamp,
-						})
-					}
-				} catch (error) {
-					this.logger?.error("Failed to sync session", "SessionManager", {
-						taskId,
-						error: error instanceof Error ? error.message : String(error),
+					this.logger?.debug("Emitted session_synced event", "SessionManager", {
+						sessionId,
+						updatedAt: updatedAtTimestamp,
 					})
+				}
+			} catch (error) {
+				this.logger?.error("Failed to sync session", "SessionManager", {
+					taskId,
+					error: error instanceof Error ? error.message : String(error),
+				})
 
-					const token = await this.getToken?.()
+				const token = await this.getToken?.()
 
-					if (token) {
-						this.tokenValid[token] = undefined
-					}
+				if (token) {
+					this.tokenValid[token] = undefined
 				}
 			}
+		}
 
-			if (lastItem) {
-				this.lastActiveSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null
+		if (lastItem) {
+			this.lastActiveSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null
 
-				if (this.lastActiveSessionId) {
-					this.sessionPersistenceManager.setLastSession(this.lastActiveSessionId)
-				}
+			if (this.lastActiveSessionId) {
+				this.sessionPersistenceManager.setLastSession(this.lastActiveSessionId)
 			}
-
-			this.logger?.debug("Session sync completed", "SessionManager", {
-				lastSessionId: this.lastActiveSessionId,
-				remainingQueueLength: this.queue.length,
-			})
-		} finally {
-			this.isSyncing = false
 		}
+
+		this.logger?.debug("Session sync completed", "SessionManager", {
+			lastSessionId: this.lastActiveSessionId,
+			remainingQueueLength: this.queue.length,
+		})
 	}
 
-	/**
-	 * use this when exiting the process
-	 */
-	destroy() {
-		this.logger?.debug("Destroying SessionManager", "SessionManager")
+	async doSync(force = false) {
+		this.logger?.debug("Doing sync", "SessionManager")
+
+		if (this.pendingSync) {
+			this.logger?.debug("Found pending sync", "SessionManager")
 
-		if (!this.pendingSync) {
-			this.pendingSync = this.syncSession()
+			if (!force) {
+				this.logger?.debug("Not forced, returning pending sync", "SessionManager")
+
+				return this.pendingSync
+			} else {
+				this.logger?.debug("Forced, syncing despite pending sync", "SessionManager")
+			}
 		}
 
+		this.logger?.debug("Creating new sync", "SessionManager")
+
+		this.pendingSync = this.syncSession()
+
+		let pendingSync = this.pendingSync
+
+		void (async () => {
+			try {
+				await pendingSync
+			} finally {
+				if (this.pendingSync === pendingSync) {
+					this.pendingSync = null
+				}
+
+				this.logger?.debug("Nulling pending sync after resolution", "SessionManager")
+			}
+		})()
+
 		return this.pendingSync
 	}
 
@@ -1077,10 +1132,6 @@ Summary:`
 
 			cleanedSummary = cleanedSummary.replace(/^["']|["']$/g, "")
 
-			if (cleanedSummary.length > 140) {
-				cleanedSummary = cleanedSummary.substring(0, 137) + "..."
-			}
-
 			if (cleanedSummary) {
 				return cleanedSummary
 			}

+ 206 - 38
src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts

@@ -44,6 +44,7 @@ vi.mock("../SessionClient", () => ({
 		share: vi.fn(),
 		fork: vi.fn(),
 		uploadBlob: vi.fn(),
+		tokenValid: vi.fn().mockResolvedValue(true),
 	})),
 	CliSessionSharedState: {
 		Public: "public",
@@ -95,7 +96,6 @@ describe("SessionManager", () => {
 
 		const privateInstance = (SessionManager as unknown as { instance: SessionManager }).instance
 		if (privateInstance) {
-			;(privateInstance as unknown as { timer: NodeJS.Timeout | null }).timer = null
 			;(privateInstance as unknown as { sessionClient: SessionClient | undefined }).sessionClient = undefined
 			;(
 				privateInstance as unknown as { sessionPersistenceManager: SessionPersistenceManager | undefined }
@@ -124,10 +124,6 @@ describe("SessionManager", () => {
 			expect(manager.sessionPersistenceManager).toBeDefined()
 		})
 
-		it("should set up sync interval timer", () => {
-			expect(vi.getTimerCount()).toBe(1)
-		})
-
 		it("should initialize pendingSync as null", () => {
 			const pendingSync = (manager as unknown as { pendingSync: Promise<void> | null }).pendingSync
 			expect(pendingSync).toBeNull()
@@ -353,6 +349,30 @@ describe("SessionManager", () => {
 			expect(mockDependencies.onSessionRestored).toHaveBeenCalled()
 		})
 
+		it("should add restored session to verified cache", async () => {
+			const mockSession: SessionWithSignedUrls = {
+				session_id: "session-123",
+				title: "Test Session",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				api_conversation_history_blob_url: null,
+				task_metadata_blob_url: null,
+				ui_messages_blob_url: null,
+				git_state_blob_url: null,
+				version: SessionManager.VERSION,
+			}
+
+			vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession)
+
+			const verifiedSessions = (manager as unknown as { verifiedSessions: Set<string> }).verifiedSessions
+			verifiedSessions.clear()
+			expect(verifiedSessions.has("session-123")).toBe(false)
+
+			await manager.restoreSession("session-123")
+
+			expect(verifiedSessions.has("session-123")).toBe(true)
+		})
+
 		it("should persist task-to-session mapping and set lastActiveSessionId when restoring session", async () => {
 			const mockSession: SessionWithSignedUrls = {
 				session_id: "session-123",
@@ -574,6 +594,9 @@ describe("SessionManager", () => {
 					uiMessagesFilePath: "/path/to/ui_messages.json",
 				}),
 			}
+
+			const verifiedSessions = (manager as unknown as { verifiedSessions: Set<string> }).verifiedSessions
+			verifiedSessions.clear()
 		})
 
 		it("should throw error when manager not initialized", async () => {
@@ -584,15 +607,131 @@ describe("SessionManager", () => {
 			)
 		})
 
-		it("should return existing session ID when task is already mapped", async () => {
+		it("should return existing session ID when task is already mapped and session exists", async () => {
 			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("existing-session-123")
+			vi.mocked(manager.sessionClient!.get).mockResolvedValue({
+				session_id: "existing-session-123",
+				title: "Existing Session",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				version: SessionManager.VERSION,
+			})
 
 			const result = await manager.getSessionFromTask("task-123", mockTaskDataProvider)
 
 			expect(result).toBe("existing-session-123")
+			expect(manager.sessionClient!.get).toHaveBeenCalledWith({
+				session_id: "existing-session-123",
+				include_blob_urls: false,
+			})
+			expect(manager.sessionClient!.create).not.toHaveBeenCalled()
+		})
+
+		it("should verify session existence and cache the result", async () => {
+			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("existing-session-123")
+			vi.mocked(manager.sessionClient!.get).mockResolvedValue({
+				session_id: "existing-session-123",
+				title: "Existing Session",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				version: SessionManager.VERSION,
+			})
+
+			await manager.getSessionFromTask("task-123", mockTaskDataProvider)
+
+			const verifiedSessions = (manager as unknown as { verifiedSessions: Set<string> }).verifiedSessions
+			expect(verifiedSessions.has("existing-session-123")).toBe(true)
+		})
+
+		it("should skip verification for already verified sessions (cached)", async () => {
+			const verifiedSessions = (manager as unknown as { verifiedSessions: Set<string> }).verifiedSessions
+			verifiedSessions.add("cached-session-123")
+
+			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("cached-session-123")
+
+			const result = await manager.getSessionFromTask("task-123", mockTaskDataProvider)
+
+			expect(result).toBe("cached-session-123")
+			expect(manager.sessionClient!.get).not.toHaveBeenCalled()
 			expect(manager.sessionClient!.create).not.toHaveBeenCalled()
 		})
 
+		it("should create new session when existing session no longer exists (returns null)", async () => {
+			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("deleted-session-123")
+			vi.mocked(manager.sessionClient!.get).mockResolvedValue(undefined as unknown as SessionWithSignedUrls)
+			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
+			vi.mocked(manager.sessionClient!.create).mockResolvedValue({
+				session_id: "new-session-456",
+				title: "Test task",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				version: SessionManager.VERSION,
+			})
+			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
+
+			const result = await manager.getSessionFromTask("task-123", mockTaskDataProvider)
+
+			expect(result).toBe("new-session-456")
+			expect(manager.sessionClient!.get).toHaveBeenCalledWith({
+				session_id: "deleted-session-123",
+				include_blob_urls: false,
+			})
+			expect(manager.sessionClient!.create).toHaveBeenCalled()
+			expect(manager.sessionPersistenceManager!.setSessionForTask).toHaveBeenCalledWith(
+				"task-123",
+				"new-session-456",
+			)
+		})
+
+		it("should create new session when existing session verification throws error", async () => {
+			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("error-session-123")
+			vi.mocked(manager.sessionClient!.get).mockRejectedValue(new Error("Session not found"))
+			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
+			vi.mocked(manager.sessionClient!.create).mockResolvedValue({
+				session_id: "new-session-789",
+				title: "Test task",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				version: SessionManager.VERSION,
+			})
+			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
+
+			const result = await manager.getSessionFromTask("task-123", mockTaskDataProvider)
+
+			expect(result).toBe("new-session-789")
+			expect(manager.sessionClient!.get).toHaveBeenCalledWith({
+				session_id: "error-session-123",
+				include_blob_urls: false,
+			})
+			expect(manager.sessionClient!.create).toHaveBeenCalled()
+			expect(mockDependencies.logger.info).toHaveBeenCalledWith(
+				"Session verification failed, will create new session",
+				"SessionManager",
+				expect.objectContaining({
+					taskId: "task-123",
+					sessionId: "error-session-123",
+				}),
+			)
+		})
+
+		it("should add newly created session to verified cache", async () => {
+			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined)
+			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
+			vi.mocked(manager.sessionClient!.create).mockResolvedValue({
+				session_id: "brand-new-session",
+				title: "Test task",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				version: SessionManager.VERSION,
+			})
+			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
+
+			await manager.getSessionFromTask("task-123", mockTaskDataProvider)
+
+			const verifiedSessions = (manager as unknown as { verifiedSessions: Set<string> }).verifiedSessions
+			expect(verifiedSessions.has("brand-new-session")).toBe(true)
+		})
+
 		it("should create new session when task is not mapped", async () => {
 			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined)
 			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
@@ -746,15 +885,15 @@ describe("SessionManager", () => {
 			expect(result).toBe("Quoted title")
 		})
 
-		it("should truncate long generated titles", async () => {
+		it("should return long generated titles without truncation", async () => {
 			const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Test message" } as ClineMessage]
 
 			vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue("A".repeat(200))
 
 			const result = await manager.generateTitle(messages)
 
-			expect(result).toHaveLength(140)
-			expect(result?.endsWith("...")).toBe(true)
+			expect(result).toHaveLength(200)
+			expect(result).toBe("A".repeat(200))
 		})
 
 		it("should fall back to truncated message on LLM error", async () => {
@@ -795,35 +934,6 @@ describe("SessionManager", () => {
 		})
 	})
 
-	describe("destroy", () => {
-		it("should return a promise", async () => {
-			const syncSessionSpy = vi.spyOn(manager as unknown as { syncSession: () => Promise<void> }, "syncSession")
-			syncSessionSpy.mockResolvedValue(undefined)
-
-			const result = manager.destroy()
-
-			expect(result).toBeInstanceOf(Promise)
-		})
-
-		it("should return existing pendingSync when one exists", async () => {
-			const existingPromise = Promise.resolve()
-			;(manager as unknown as { pendingSync: Promise<void> | null }).pendingSync = existingPromise
-
-			const result = manager.destroy()
-
-			expect(result).toBe(existingPromise)
-		})
-
-		it("should log debug message when destroying", async () => {
-			const syncSessionSpy = vi.spyOn(manager as unknown as { syncSession: () => Promise<void> }, "syncSession")
-			syncSessionSpy.mockResolvedValue(undefined)
-
-			manager.destroy()
-
-			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Destroying SessionManager", "SessionManager")
-		})
-	})
-
 	describe("getGitState patch size limit", () => {
 		it("should return patch when size is under the limit", async () => {
 			const smallPatch = "a".repeat(1000)
@@ -933,4 +1043,62 @@ describe("SessionManager", () => {
 			expect(result.patch).toBe("")
 		})
 	})
+	describe("restoreSession version mismatch", () => {
+		it("should log warning when session version does not match", async () => {
+			const mockSession: SessionWithSignedUrls = {
+				session_id: "session-123",
+				title: "Test Session",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				api_conversation_history_blob_url: null,
+				task_metadata_blob_url: null,
+				ui_messages_blob_url: null,
+				git_state_blob_url: null,
+				version: 999,
+			}
+
+			vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession)
+
+			await manager.restoreSession("session-123")
+
+			expect(mockDependencies.logger.warn).toHaveBeenCalledWith("Session version mismatch", "SessionManager", {
+				sessionId: "session-123",
+				expectedVersion: SessionManager.VERSION,
+				actualVersion: 999,
+			})
+		})
+	})
+
+	describe("restoreSession git state restoration", () => {
+		it("should execute git restore when git_state blob is present", async () => {
+			const mockSession: SessionWithSignedUrls = {
+				session_id: "session-123",
+				title: "Test Session",
+				created_at: new Date().toISOString(),
+				updated_at: new Date().toISOString(),
+				api_conversation_history_blob_url: null,
+				task_metadata_blob_url: null,
+				ui_messages_blob_url: null,
+				git_state_blob_url: "https://storage.example.com/git_state.json",
+				version: SessionManager.VERSION,
+			}
+
+			vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession)
+
+			const gitState = {
+				head: "abc123",
+				patch: "diff content",
+				branch: "main",
+			}
+
+			global.fetch = vi.fn().mockResolvedValue({
+				ok: true,
+				json: vi.fn().mockResolvedValue(gitState),
+			})
+
+			await manager.restoreSession("session-123")
+
+			expect(global.fetch).toHaveBeenCalledWith("https://storage.example.com/git_state.json")
+		})
+	})
 })

+ 289 - 101
src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts

@@ -92,6 +92,7 @@ const createMockDependencies = (): SessionManagerDependencies => ({
 	},
 	onSessionCreated: vi.fn(),
 	onSessionRestored: vi.fn(),
+	onSessionSynced: vi.fn(),
 })
 
 describe("SessionManager.syncSession", () => {
@@ -110,17 +111,11 @@ describe("SessionManager.syncSession", () => {
 
 		const privateInstance = (SessionManager as unknown as { instance: SessionManager }).instance
 		if (privateInstance) {
-			const timer = (privateInstance as unknown as { timer: NodeJS.Timeout | null }).timer
-			if (timer) {
-				clearInterval(timer)
-			}
-			;(privateInstance as unknown as { timer: NodeJS.Timeout | null }).timer = null
 			;(privateInstance as unknown as { sessionClient: SessionClient | undefined }).sessionClient = undefined
 			;(
 				privateInstance as unknown as { sessionPersistenceManager: SessionPersistenceManager | undefined }
 			).sessionPersistenceManager = undefined
 			;(privateInstance as unknown as { queue: unknown[] }).queue = []
-			;(privateInstance as unknown as { isSyncing: boolean }).isSyncing = false
 			;(privateInstance as unknown as { taskGitUrls: Record<string, string> }).taskGitUrls = {}
 			;(privateInstance as unknown as { taskGitHashes: Record<string, string> }).taskGitHashes = {}
 			;(privateInstance as unknown as { sessionTitles: Record<string, string> }).sessionTitles = {}
@@ -158,12 +153,6 @@ describe("SessionManager.syncSession", () => {
 
 	const getQueue = () => (manager as unknown as { queue: unknown[] }).queue
 
-	const getIsSyncing = () => (manager as unknown as { isSyncing: boolean }).isSyncing
-
-	const setIsSyncing = (value: boolean) => {
-		;(manager as unknown as { isSyncing: boolean }).isSyncing = value
-	}
-
 	const getPendingSync = () => (manager as unknown as { pendingSync: Promise<void> | null }).pendingSync
 
 	const setPendingSync = (value: Promise<void> | null) => {
@@ -171,18 +160,6 @@ describe("SessionManager.syncSession", () => {
 	}
 
 	describe("sync skipping conditions", () => {
-		it("should skip sync when already syncing", async () => {
-			setIsSyncing(true)
-			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
-
-			await triggerSync()
-
-			expect(mockDependencies.logger.debug).toHaveBeenCalledWith(
-				"Sync already in progress, skipping",
-				"SessionManager",
-			)
-		})
-
 		it("should return early when queue is empty", async () => {
 			await triggerSync()
 
@@ -349,6 +326,22 @@ describe("SessionManager.syncSession", () => {
 			expect(getQueue()).toHaveLength(1)
 			expect(manager.sessionClient!.create).not.toHaveBeenCalled()
 		})
+
+		it("should handle token validity check failure gracefully", async () => {
+			clearTokenValidCache()
+			vi.mocked(manager.sessionClient!.tokenValid).mockRejectedValue(new Error("Network error"))
+
+			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
+
+			await triggerSync()
+
+			expect(mockDependencies.logger.error).toHaveBeenCalledWith(
+				"Failed to check token validity",
+				"SessionManager",
+				{ error: "Network error" },
+			)
+			expect(manager.sessionClient!.uploadBlob).not.toHaveBeenCalled()
+		})
 	})
 
 	describe("session creation", () => {
@@ -708,33 +701,6 @@ describe("SessionManager.syncSession", () => {
 		})
 	})
 
-	describe("isSyncing flag", () => {
-		it("should reset isSyncing to false after sync completes", async () => {
-			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
-			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
-			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
-
-			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
-
-			await triggerSync()
-
-			expect(getIsSyncing()).toBe(false)
-		})
-
-		it("should reset isSyncing to false even on error", async () => {
-			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
-			vi.mocked(readFileSync).mockImplementation(() => {
-				throw new Error("Read error")
-			})
-
-			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
-
-			await triggerSync()
-
-			expect(getIsSyncing()).toBe(false)
-		})
-	})
-
 	describe("error handling", () => {
 		it("should continue processing other tasks when one fails", async () => {
 			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask)
@@ -839,6 +805,12 @@ describe("SessionManager.syncSession", () => {
 				const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }]
 				vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages))
 				vi.mocked(manager.sessionClient!.get).mockRejectedValueOnce(new Error("Network error"))
+				vi.mocked(manager.sessionClient!.update).mockResolvedValue({
+					session_id: "session-123",
+					title: "Create a login form",
+					updated_at: new Date().toISOString(),
+					version: SessionManager.VERSION,
+				})
 
 				manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
 
@@ -847,9 +819,6 @@ describe("SessionManager.syncSession", () => {
 
 				await new Promise((resolve) => setTimeout(resolve, 100))
 
-				const sessionTitles = (manager as unknown as { sessionTitles: Record<string, string> }).sessionTitles
-				expect(sessionTitles["session-123"]).toBe("")
-
 				expect(mockDependencies.logger.error).toHaveBeenCalledWith(
 					"Failed to generate session title",
 					"SessionManager",
@@ -859,6 +828,14 @@ describe("SessionManager.syncSession", () => {
 					}),
 				)
 
+				expect(manager.sessionClient!.update).toHaveBeenCalledWith({
+					session_id: "session-123",
+					title: "Create a login form",
+				})
+
+				const sessionTitles = (manager as unknown as { sessionTitles: Record<string, string> }).sessionTitles
+				expect(sessionTitles["session-123"]).toBe("Create a login form")
+
 				vi.mocked(manager.sessionClient!.get).mockResolvedValue({
 					session_id: "session-123",
 					title: "",
@@ -887,6 +864,112 @@ describe("SessionManager.syncSession", () => {
 				expect(manager.sessionClient!.get).toHaveBeenCalledTimes(2)
 			})
 
+			it("should handle renameSession failure when falling back to local title", async () => {
+				const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }]
+				vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages))
+				vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
+
+				vi.mocked(manager.sessionClient!.get).mockResolvedValueOnce({
+					session_id: "session-123",
+					title: "",
+					created_at: new Date().toISOString(),
+					updated_at: new Date().toISOString(),
+					api_conversation_history_blob_url: null,
+					task_metadata_blob_url: null,
+					ui_messages_blob_url: null,
+					git_state_blob_url: null,
+					version: SessionManager.VERSION,
+				})
+
+				vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockRejectedValueOnce(
+					new Error("LLM generation failed"),
+				)
+
+				let updateCallCount = 0
+				vi.mocked(manager.sessionClient!.update).mockImplementation(async (params) => {
+					updateCallCount++
+					if (params && "title" in params) {
+						throw new Error("Update failed")
+					}
+					return {
+						session_id: params?.session_id || "session-123",
+						title: "",
+						updated_at: new Date().toISOString(),
+						version: SessionManager.VERSION,
+					}
+				})
+
+				const taskGitUrls = (manager as unknown as { taskGitUrls: Record<string, string> }).taskGitUrls
+				taskGitUrls["task-123"] = "https://github.com/test/repo.git"
+
+				manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
+
+				vi.useRealTimers()
+				await triggerSync()
+
+				await new Promise((resolve) => setTimeout(resolve, 100))
+
+				expect(mockDependencies.logger.error).toHaveBeenCalledWith(
+					"Failed to generate session title",
+					"SessionManager",
+					expect.objectContaining({
+						sessionId: "session-123",
+					}),
+				)
+
+				expect(mockDependencies.logger.error).toHaveBeenCalledWith(
+					"Failed to update session title using local title",
+					"SessionManager",
+					expect.objectContaining({
+						sessionId: "session-123",
+						error: "Update failed",
+					}),
+				)
+			})
+
+			it("should not call renameSession when local title is empty", async () => {
+				const uiMessages = [{ type: "say", say: "text" }]
+				vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages))
+				vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
+
+				vi.mocked(manager.sessionClient!.get).mockResolvedValueOnce({
+					session_id: "session-123",
+					title: "",
+					created_at: new Date().toISOString(),
+					updated_at: new Date().toISOString(),
+					api_conversation_history_blob_url: null,
+					task_metadata_blob_url: null,
+					ui_messages_blob_url: null,
+					git_state_blob_url: null,
+					version: SessionManager.VERSION,
+				})
+
+				vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockRejectedValueOnce(
+					new Error("LLM generation failed"),
+				)
+
+				const updateMock = vi.mocked(manager.sessionClient!.update)
+				updateMock.mockClear()
+
+				manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
+
+				vi.useRealTimers()
+				await triggerSync()
+
+				await new Promise((resolve) => setTimeout(resolve, 100))
+
+				expect(mockDependencies.logger.error).toHaveBeenCalledWith(
+					"Failed to generate session title",
+					"SessionManager",
+					expect.objectContaining({
+						sessionId: "session-123",
+					}),
+				)
+
+				const updateCallsWithTitle = updateMock.mock.calls.filter((call) => call[0] && "title" in call[0])
+				expect(updateCallsWithTitle).toHaveLength(0)
+			})
+
 			it("should set pending title marker to prevent concurrent title generation", async () => {
 				const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }]
 				vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages))
@@ -927,7 +1010,7 @@ describe("SessionManager.syncSession", () => {
 		})
 
 		describe("concurrent sync attempts", () => {
-			it("should prevent concurrent syncs via isSyncing flag", async () => {
+			it("should prevent concurrent syncs via pendingSync", async () => {
 				vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
 				vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
 
@@ -954,10 +1037,8 @@ describe("SessionManager.syncSession", () => {
 
 				await Promise.all([sync1, sync2])
 
-				expect(mockDependencies.logger.debug).toHaveBeenCalledWith(
-					"Sync already in progress, skipping",
-					"SessionManager",
-				)
+				// In the event-based approach, the second sync should return the existing pending sync
+				expect(sync2).toBeInstanceOf(Promise)
 			})
 
 			it("should process queued items after blocked sync completes", async () => {
@@ -980,7 +1061,8 @@ describe("SessionManager.syncSession", () => {
 
 				await sync1
 
-				expect(getQueue()).toHaveLength(1)
+				// In the event-based approach, the queue should be empty after sync completes
+				expect(getQueue()).toHaveLength(0)
 
 				await triggerSync()
 
@@ -1306,22 +1388,7 @@ describe("SessionManager.syncSession", () => {
 			})
 		})
 
-		describe("pendingSync tracking in interval", () => {
-			it("should skip interval sync when pendingSync exists", async () => {
-				vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
-				vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
-				vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
-
-				const existingPromise = new Promise<void>((resolve) => setTimeout(resolve, 200))
-				setPendingSync(existingPromise)
-
-				manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
-
-				vi.advanceTimersByTime(SessionManager.SYNC_INTERVAL)
-
-				expect(manager.sessionClient!.uploadBlob).not.toHaveBeenCalled()
-			})
-
+		describe("pendingSync tracking", () => {
 			it("should clear pendingSync after sync completes via direct call", async () => {
 				vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
 				vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
@@ -1337,66 +1404,187 @@ describe("SessionManager.syncSession", () => {
 				expect(getPendingSync()).toBeNull()
 			})
 
-			it("should set pendingSync during sync execution via direct call", async () => {
+			it("should set pendingSync during sync execution via doSync", async () => {
 				vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
 				vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
 
 				let pendingSyncDuringUpload: Promise<void> | null = null
 				vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async () => {
-					pendingSyncDuringUpload = getIsSyncing() ? Promise.resolve() : null
+					pendingSyncDuringUpload = getPendingSync()
 					return { updated_at: new Date().toISOString() }
 				})
 
 				manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
 
-				await triggerSync()
+				await manager.doSync()
 
-				expect(pendingSyncDuringUpload).not.toBeNull()
+				expect(pendingSyncDuringUpload).toBeInstanceOf(Promise)
 			})
 		})
 	})
 
-	describe("destroy method", () => {
-		it("should trigger final sync on destroy", async () => {
+	describe("doSync", () => {
+		it("should not create new sync when pendingSync exists and not forced", async () => {
+			let resolveExisting: () => void
+			const existingPromise = new Promise<void>((resolve) => {
+				resolveExisting = resolve
+			})
+			;(manager as unknown as { pendingSync: Promise<void> | null }).pendingSync = existingPromise
+
+			manager.doSync()
+
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Found pending sync", "SessionManager")
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith(
+				"Not forced, returning pending sync",
+				"SessionManager",
+			)
+			expect(mockDependencies.logger.debug).not.toHaveBeenCalledWith("Creating new sync", "SessionManager")
+
+			resolveExisting!()
+			await existingPromise
+		})
+
+		it("should create new sync when forced despite pending sync", async () => {
+			let resolveExisting: () => void
+			const existingPromise = new Promise<void>((resolve) => {
+				resolveExisting = resolve
+			})
+			;(manager as unknown as { pendingSync: Promise<void> | null }).pendingSync = existingPromise
+
+			manager.doSync(true)
+
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Found pending sync", "SessionManager")
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith(
+				"Forced, syncing despite pending sync",
+				"SessionManager",
+			)
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Creating new sync", "SessionManager")
+
+			resolveExisting!()
+			await existingPromise
+		})
+
+		it("should create new sync when no pending sync exists", async () => {
+			;(manager as unknown as { pendingSync: Promise<void> | null }).pendingSync = null
+
+			const result = manager.doSync()
+
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Creating new sync", "SessionManager")
+			expect(result).toBeInstanceOf(Promise)
+
+			await result
+		})
+
+		it("should set pendingSync when creating new sync", async () => {
+			;(manager as unknown as { pendingSync: Promise<void> | null }).pendingSync = null
+
+			manager.doSync()
+
+			const pendingSync = (manager as unknown as { pendingSync: Promise<void> | null }).pendingSync
+			expect(pendingSync).not.toBeNull()
+			expect(pendingSync).toBeInstanceOf(Promise)
+
+			await pendingSync
+		})
+
+		it("should log debug messages during sync", async () => {
+			;(manager as unknown as { pendingSync: Promise<void> | null }).pendingSync = null
+
+			await manager.doSync()
+
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Doing sync", "SessionManager")
+			expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Creating new sync", "SessionManager")
+		})
+	})
+
+	describe("onSessionSynced callback", () => {
+		beforeEach(() => {
 			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
 			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
-			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
+			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({
+				updated_at: "2024-01-15T10:30:00.000Z",
+			})
+			const taskGitUrls = (manager as unknown as { taskGitUrls: Record<string, string> }).taskGitUrls
+			taskGitUrls["task-123"] = "https://github.com/test/repo.git"
+			const taskGitHashes = (manager as unknown as { taskGitHashes: Record<string, string> }).taskGitHashes
+			taskGitHashes["task-123"] = "fixed-hash-to-skip-git-upload"
+			const sessionTitles = (manager as unknown as { sessionTitles: Record<string, string> }).sessionTitles
+			sessionTitles["session-123"] = "Existing title"
+			const sessionUpdatedAt = (manager as unknown as { sessionUpdatedAt: Record<string, string> })
+				.sessionUpdatedAt
+			delete sessionUpdatedAt["session-123"]
+		})
 
+		it("should emit onSessionSynced callback after successful sync", async () => {
 			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
 
-			const destroyPromise = manager.destroy()
+			await triggerSync()
+
+			expect(mockDependencies.onSessionSynced).toHaveBeenCalledWith({
+				sessionId: "session-123",
+				updatedAt: new Date("2024-01-15T10:30:00.000Z").getTime(),
+				timestamp: expect.any(Number),
+				event: "session_synced",
+			})
+		})
 
-			await destroyPromise
+		it("should emit onSessionSynced with the highest timestamp from multiple uploads", async () => {
+			vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async (_sessionId, blobType) => {
+				switch (blobType) {
+					case "ui_messages":
+						return { updated_at: "2024-01-15T10:00:00.000Z" }
+					case "api_conversation_history":
+						return { updated_at: "2024-01-15T12:00:00.000Z" }
+					default:
+						return { updated_at: "2024-01-15T08:00:00.000Z" }
+				}
+			})
 
-			expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith(
-				"session-123",
-				"ui_messages",
-				expect.any(Array),
-			)
+			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/ui.json")
+			manager.handleFileUpdate("task-123", "apiConversationHistoryPath", "/path/to/api.json")
+
+			await triggerSync()
+
+			expect(mockDependencies.onSessionSynced).toHaveBeenCalledWith({
+				sessionId: "session-123",
+				updatedAt: new Date("2024-01-15T12:00:00.000Z").getTime(),
+				timestamp: expect.any(Number),
+				event: "session_synced",
+			})
 		})
 
-		it("should return existing pendingSync if one is in progress", async () => {
-			const existingPromise = Promise.resolve()
-			setPendingSync(existingPromise)
+		it("should not emit onSessionSynced when no timestamp is available", async () => {
+			const sessionUpdatedAt = (manager as unknown as { sessionUpdatedAt: Record<string, string> })
+				.sessionUpdatedAt
+			delete sessionUpdatedAt["session-123"]
+
+			vi.mocked(manager.sessionClient!.uploadBlob).mockRejectedValue(new Error("Upload failed"))
+
+			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json")
 
-			const result = manager.destroy()
+			await triggerSync()
 
-			expect(result).toBe(existingPromise)
+			expect(mockDependencies.onSessionSynced).not.toHaveBeenCalled()
 		})
+	})
 
-		it("should flush queue items during destroy", async () => {
+	describe("automatic sync trigger", () => {
+		it(`should trigger sync when queue exceeds ${SessionManager.QUEUE_FLUSH_THRESHOLD} items`, async () => {
 			vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123")
 			vi.mocked(readFileSync).mockReturnValue(JSON.stringify([]))
 			vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ updated_at: new Date().toISOString() })
 
-			manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json")
-			manager.handleFileUpdate("task-123", "apiConversationHistoryPath", "/path/to/file2.json")
+			const doSyncSpy = vi.spyOn(manager, "doSync")
 
-			expect(getQueue()).toHaveLength(2)
+			for (let i = 0; i < SessionManager.QUEUE_FLUSH_THRESHOLD; i++) {
+				manager.handleFileUpdate(`task-${i}`, "uiMessagesPath", `/path/to/file${i}.json`)
+			}
 
-			await manager.destroy()
+			expect(doSyncSpy).not.toHaveBeenCalled()
 
-			expect(getQueue()).toHaveLength(0)
+			manager.handleFileUpdate("task-6", "uiMessagesPath", "/path/to/file6.json")
+
+			expect(doSyncSpy).toHaveBeenCalled()
 		})
 	})
 })

+ 5 - 0
src/shared/kilocode/errorUtils.ts

@@ -2,6 +2,11 @@ export function stringifyError(error: unknown) {
 	return error instanceof Error ? error.stack || error.message : String(error)
 }
 
+/**
+ * Error message thrown when the KiloCode token is missing or invalid.
+ */
+export const KILOCODE_TOKEN_REQUIRED_ERROR = "KiloCode token + baseUrl is required to fetch models"
+
 export function isPaymentRequiredError(error: any) {
 	return !!(error && error.status === 402)
 }

+ 1 - 0
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 = {

+ 685 - 0
src/test-llm-autocompletion/html-report.ts

@@ -0,0 +1,685 @@
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+import { testCases, TestCase, ContextFile } from "./test-cases.js"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+const OUTPUT_DIR = path.join(__dirname, "html-output")
+const APPROVALS_DIR = path.join(__dirname, "approvals")
+const CURSOR_MARKER = "<<<AUTOCOMPLETE_HERE>>>"
+
+interface ApprovalFile {
+	filename: string
+	type: "approved" | "rejected"
+	number: number
+	content: string
+}
+
+interface ParsedApprovalContent {
+	prefix: string
+	completion: string
+	suffix: string
+}
+
+/**
+ * Parse an approval content to extract prefix, completion, and suffix
+ * by comparing with the original input that contains the cursor marker.
+ */
+function parseApprovalContent(input: string, approvalContent: string): ParsedApprovalContent {
+	const cursorIndex = input.indexOf(CURSOR_MARKER)
+	if (cursorIndex === -1) {
+		// No cursor marker found, treat entire content as completion
+		return { prefix: "", completion: approvalContent, suffix: "" }
+	}
+
+	const inputPrefix = input.substring(0, cursorIndex)
+	const inputSuffix = input.substring(cursorIndex + CURSOR_MARKER.length)
+
+	// The approval content should start with the prefix and end with the suffix
+	// The completion is what's in between
+	const trimmedPrefix = inputPrefix.trimEnd()
+	const trimmedSuffix = inputSuffix.trimStart()
+
+	// Find where the prefix ends in the approval content
+	// We need to handle the case where whitespace might differ slightly
+	let prefixEndIndex = 0
+	if (trimmedPrefix.length > 0) {
+		// Find the prefix in the approval content
+		const prefixIndex = approvalContent.indexOf(trimmedPrefix)
+		if (prefixIndex === 0) {
+			prefixEndIndex = trimmedPrefix.length
+		}
+	}
+
+	// Find where the suffix starts in the approval content
+	let suffixStartIndex = approvalContent.length
+	if (trimmedSuffix.length > 0) {
+		// Find the suffix in the approval content (search from the end)
+		const suffixIndex = approvalContent.lastIndexOf(trimmedSuffix)
+		if (suffixIndex !== -1 && suffixIndex >= prefixEndIndex) {
+			suffixStartIndex = suffixIndex
+		}
+	}
+
+	return {
+		prefix: approvalContent.substring(0, prefixEndIndex),
+		completion: approvalContent.substring(prefixEndIndex, suffixStartIndex),
+		suffix: approvalContent.substring(suffixStartIndex),
+	}
+}
+
+/**
+ * Format approval content with prefix/suffix in grey and completion highlighted
+ */
+function formatApprovalContent(input: string, approvalContent: string): string {
+	const { prefix, completion, suffix } = parseApprovalContent(input, approvalContent)
+
+	const escapedPrefix = escapeHtml(prefix)
+	const escapedCompletion = escapeHtml(completion)
+	const escapedSuffix = escapeHtml(suffix)
+
+	// Wrap prefix and suffix in grey spans, completion stays normal
+	let result = ""
+	if (escapedPrefix) {
+		result += `<span class="context-code">${escapedPrefix}</span>`
+	}
+	if (escapedCompletion) {
+		result += `<span class="completion-code">${escapedCompletion}</span>`
+	}
+	if (escapedSuffix) {
+		result += `<span class="context-code">${escapedSuffix}</span>`
+	}
+
+	return result || escapeHtml(approvalContent)
+}
+
+interface TestCaseWithApprovals extends TestCase {
+	approvals: ApprovalFile[]
+}
+
+function loadApprovals(category: string, testName: string): ApprovalFile[] {
+	const categoryDir = path.join(APPROVALS_DIR, category)
+	if (!fs.existsSync(categoryDir)) {
+		return []
+	}
+
+	const approvals: ApprovalFile[] = []
+	const pattern = new RegExp(`^${testName}\\.(approved|rejected)\\.(\\d+)\\.txt$`)
+	const files = fs.readdirSync(categoryDir).filter((f) => pattern.test(f))
+
+	for (const file of files) {
+		const match = file.match(pattern)
+		if (match) {
+			const filePath = path.join(categoryDir, file)
+			const content = fs.readFileSync(filePath, "utf-8")
+			approvals.push({
+				filename: file,
+				type: match[1] as "approved" | "rejected",
+				number: parseInt(match[2], 10),
+				content,
+			})
+		}
+	}
+
+	approvals.sort((a, b) => a.number - b.number)
+	return approvals
+}
+
+function escapeHtml(text: string): string {
+	return text
+		.replace(/&/g, "&amp;")
+		.replace(/</g, "&lt;")
+		.replace(/>/g, "&gt;")
+		.replace(/"/g, "&quot;")
+		.replace(/'/g, "&#039;")
+}
+
+function highlightCursor(text: string): string {
+	const escaped = escapeHtml(text)
+	return escaped.replace(escapeHtml(CURSOR_MARKER), '<span class="cursor-marker">⟨CURSOR⟩</span>')
+}
+
+function generateIndexHtml(testCasesWithApprovals: TestCaseWithApprovals[]): string {
+	const byCategory = new Map<string, TestCaseWithApprovals[]>()
+	for (const tc of testCasesWithApprovals) {
+		if (!byCategory.has(tc.category)) {
+			byCategory.set(tc.category, [])
+		}
+		byCategory.get(tc.category)!.push(tc)
+	}
+
+	let categoriesHtml = ""
+	for (const [category, cases] of byCategory) {
+		let casesHtml = ""
+		for (const tc of cases) {
+			const approvedCount = tc.approvals.filter((a) => a.type === "approved").length
+			const rejectedCount = tc.approvals.filter((a) => a.type === "rejected").length
+			const statusClass =
+				approvedCount > 0 && rejectedCount === 0
+					? "all-approved"
+					: approvedCount === 0 && rejectedCount > 0
+						? "all-rejected"
+						: "mixed"
+
+			casesHtml += `
+				<div class="test-case-item ${statusClass}">
+					<a href="${category}/${tc.name}.html">${tc.name}</a>
+					<span class="counts">
+						<span class="approved-count" title="Approved">✓ ${approvedCount}</span>
+						<span class="rejected-count" title="Rejected">✗ ${rejectedCount}</span>
+					</span>
+				</div>
+			`
+		}
+
+		categoriesHtml += `
+			<div class="category">
+				<h2>${category}</h2>
+				<div class="test-cases-list">
+					${casesHtml}
+				</div>
+			</div>
+		`
+	}
+
+	return `<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>LLM Autocompletion Test Cases</title>
+	<style>
+		* { box-sizing: border-box; }
+		body {
+			font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+			margin: 0;
+			padding: 20px;
+			background: #1e1e1e;
+			color: #d4d4d4;
+		}
+		h1 {
+			color: #569cd6;
+			border-bottom: 2px solid #569cd6;
+			padding-bottom: 10px;
+		}
+		h2 {
+			color: #4ec9b0;
+			margin-top: 30px;
+		}
+		.category {
+			margin-bottom: 30px;
+		}
+		.test-cases-list {
+			display: flex;
+			flex-wrap: wrap;
+			gap: 10px;
+		}
+		.test-case-item {
+			background: #2d2d2d;
+			padding: 10px 15px;
+			border-radius: 5px;
+			display: flex;
+			align-items: center;
+			gap: 15px;
+			border-left: 4px solid #666;
+		}
+		.test-case-item.all-approved { border-left-color: #4caf50; }
+		.test-case-item.all-rejected { border-left-color: #f44336; }
+		.test-case-item.mixed { border-left-color: #ff9800; }
+		.test-case-item a {
+			color: #9cdcfe;
+			text-decoration: none;
+		}
+		.test-case-item a:hover {
+			text-decoration: underline;
+		}
+		.counts {
+			display: flex;
+			gap: 10px;
+			font-size: 0.85em;
+		}
+		.approved-count { color: #4caf50; }
+		.rejected-count { color: #f44336; }
+		.summary {
+			background: #2d2d2d;
+			padding: 15px 20px;
+			border-radius: 5px;
+			margin-bottom: 20px;
+		}
+		.summary-stats {
+			display: flex;
+			gap: 30px;
+		}
+		.stat {
+			display: flex;
+			flex-direction: column;
+		}
+		.stat-value {
+			font-size: 2em;
+			font-weight: bold;
+		}
+		.stat-label {
+			color: #888;
+		}
+	</style>
+</head>
+<body>
+	<h1>LLM Autocompletion Test Cases</h1>
+	
+	<div class="summary">
+		<div class="summary-stats">
+			<div class="stat">
+				<span class="stat-value">${testCasesWithApprovals.length}</span>
+				<span class="stat-label">Test Cases</span>
+			</div>
+			<div class="stat">
+				<span class="stat-value approved-count">${testCasesWithApprovals.reduce((sum, tc) => sum + tc.approvals.filter((a) => a.type === "approved").length, 0)}</span>
+				<span class="stat-label">Approved Outputs</span>
+			</div>
+			<div class="stat">
+				<span class="stat-value rejected-count">${testCasesWithApprovals.reduce((sum, tc) => sum + tc.approvals.filter((a) => a.type === "rejected").length, 0)}</span>
+				<span class="stat-label">Rejected Outputs</span>
+			</div>
+		</div>
+	</div>
+
+	${categoriesHtml}
+</body>
+</html>`
+}
+
+function generateTestCaseHtml(tc: TestCaseWithApprovals, allTestCases: TestCaseWithApprovals[]): string {
+	const sameCategoryTests = allTestCases.filter((t) => t.category === tc.category)
+	const currentIndex = sameCategoryTests.findIndex((t) => t.name === tc.name)
+	const prevTest = currentIndex > 0 ? sameCategoryTests[currentIndex - 1] : null
+	const nextTest = currentIndex < sameCategoryTests.length - 1 ? sameCategoryTests[currentIndex + 1] : null
+
+	const approvedOutputs = tc.approvals.filter((a) => a.type === "approved")
+	const rejectedOutputs = tc.approvals.filter((a) => a.type === "rejected")
+
+	let approvalsHtml = ""
+
+	if (approvedOutputs.length > 0) {
+		let approvedItems = ""
+		for (const approval of approvedOutputs) {
+			approvedItems += `
+				<div class="approval-item approved">
+					<div class="approval-header">
+						<span class="approval-badge approved">✓ Approved #${approval.number}</span>
+						<span class="approval-filename">${approval.filename}</span>
+					</div>
+					<pre class="code-block">${formatApprovalContent(tc.input, approval.content)}</pre>
+				</div>
+			`
+		}
+		approvalsHtml += `
+			<div class="approvals-section">
+				<h3 class="approved-header">Approved Outputs (${approvedOutputs.length})</h3>
+				${approvedItems}
+			</div>
+		`
+	}
+
+	if (rejectedOutputs.length > 0) {
+		let rejectedItems = ""
+		for (const rejection of rejectedOutputs) {
+			rejectedItems += `
+				<div class="approval-item rejected">
+					<div class="approval-header">
+						<span class="approval-badge rejected">✗ Rejected #${rejection.number}</span>
+						<span class="approval-filename">${rejection.filename}</span>
+					</div>
+					<pre class="code-block">${formatApprovalContent(tc.input, rejection.content)}</pre>
+				</div>
+			`
+		}
+		approvalsHtml += `
+			<div class="approvals-section">
+				<h3 class="rejected-header">Rejected Outputs (${rejectedOutputs.length})</h3>
+				${rejectedItems}
+			</div>
+		`
+	}
+
+	if (tc.approvals.length === 0) {
+		approvalsHtml = `<div class="no-approvals">No approvals or rejections recorded yet.</div>`
+	}
+
+	let contextFilesHtml = ""
+	if (tc.contextFiles.length > 0) {
+		let contextItems = ""
+		for (const cf of tc.contextFiles) {
+			contextItems += `
+				<div class="context-file">
+					<div class="context-file-header">${escapeHtml(cf.filepath)}</div>
+					<pre class="code-block">${escapeHtml(cf.content)}</pre>
+				</div>
+			`
+		}
+		contextFilesHtml = `
+			<div class="context-files-section">
+				<h3>Context Files</h3>
+				${contextItems}
+			</div>
+		`
+	}
+
+	return `<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>${tc.category}/${tc.name} - LLM Autocompletion Test</title>
+	<style>
+		* { box-sizing: border-box; }
+		body {
+			font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+			margin: 0;
+			padding: 0;
+			background: #1e1e1e;
+			color: #d4d4d4;
+			height: 100vh;
+			display: flex;
+			flex-direction: column;
+		}
+		.header {
+			background: #252526;
+			padding: 15px 20px;
+			border-bottom: 1px solid #3c3c3c;
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+			flex-shrink: 0;
+		}
+		.header h1 {
+			margin: 0;
+			font-size: 1.2em;
+			color: #569cd6;
+		}
+		.nav {
+			display: flex;
+			gap: 10px;
+		}
+		.nav a {
+			color: #9cdcfe;
+			text-decoration: none;
+			padding: 5px 10px;
+			background: #3c3c3c;
+			border-radius: 3px;
+		}
+		.nav a:hover {
+			background: #4c4c4c;
+		}
+		.nav a.disabled {
+			color: #666;
+			pointer-events: none;
+		}
+		.breadcrumb {
+			color: #888;
+			font-size: 0.9em;
+		}
+		.breadcrumb a {
+			color: #9cdcfe;
+			text-decoration: none;
+		}
+		.breadcrumb a:hover {
+			text-decoration: underline;
+		}
+		.main-content {
+			display: flex;
+			flex: 1;
+			overflow: hidden;
+		}
+		.panel {
+			flex: 1;
+			overflow-y: auto;
+			padding: 20px;
+			border-right: 1px solid #3c3c3c;
+		}
+		.panel:last-child {
+			border-right: none;
+		}
+		.panel h2 {
+			margin-top: 0;
+			color: #4ec9b0;
+			font-size: 1.1em;
+			border-bottom: 1px solid #3c3c3c;
+			padding-bottom: 10px;
+		}
+		.panel h3 {
+			font-size: 1em;
+			margin-top: 20px;
+		}
+		.approved-header { color: #4caf50; }
+		.rejected-header { color: #f44336; }
+		.meta-info {
+			background: #2d2d2d;
+			padding: 10px 15px;
+			border-radius: 5px;
+			margin-bottom: 15px;
+		}
+		.meta-row {
+			display: flex;
+			margin-bottom: 5px;
+		}
+		.meta-label {
+			color: #888;
+			width: 100px;
+			flex-shrink: 0;
+		}
+		.meta-value {
+			color: #ce9178;
+		}
+		.code-block {
+			background: #1e1e1e;
+			border: 1px solid #3c3c3c;
+			border-radius: 5px;
+			padding: 15px;
+			overflow-x: auto;
+			font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
+			font-size: 13px;
+			line-height: 1.5;
+			white-space: pre-wrap;
+			word-wrap: break-word;
+		}
+		.cursor-marker {
+			background: #ffeb3b;
+			color: #000;
+			padding: 2px 6px;
+			border-radius: 3px;
+			font-weight: bold;
+		}
+		.approval-item {
+			margin-bottom: 20px;
+			border: 1px solid #3c3c3c;
+			border-radius: 5px;
+			overflow: hidden;
+		}
+		.approval-item.approved {
+			border-color: #4caf50;
+		}
+		.approval-item.rejected {
+			border-color: #f44336;
+		}
+		.approval-header {
+			background: #2d2d2d;
+			padding: 8px 12px;
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+		}
+		.approval-badge {
+			padding: 3px 8px;
+			border-radius: 3px;
+			font-size: 0.85em;
+			font-weight: bold;
+		}
+		.approval-badge.approved {
+			background: #1b5e20;
+			color: #4caf50;
+		}
+		.approval-badge.rejected {
+			background: #b71c1c;
+			color: #f44336;
+		}
+		.approval-filename {
+			color: #888;
+			font-size: 0.85em;
+		}
+		.approval-item .code-block {
+			border: none;
+			border-radius: 0;
+			margin: 0;
+		}
+		.no-approvals {
+			color: #888;
+			font-style: italic;
+			padding: 20px;
+			text-align: center;
+		}
+		.context-file {
+			margin-bottom: 15px;
+		}
+		.context-file-header {
+			background: #2d2d2d;
+			padding: 8px 12px;
+			border-radius: 5px 5px 0 0;
+			border: 1px solid #3c3c3c;
+			border-bottom: none;
+			color: #dcdcaa;
+			font-family: monospace;
+		}
+		.context-file .code-block {
+			border-radius: 0 0 5px 5px;
+			margin-top: 0;
+		}
+		.approvals-section {
+			margin-bottom: 30px;
+		}
+		.keyboard-hint {
+			position: fixed;
+			bottom: 20px;
+			right: 20px;
+			background: #2d2d2d;
+			padding: 10px 15px;
+			border-radius: 5px;
+			font-size: 0.85em;
+			color: #888;
+		}
+		.keyboard-hint kbd {
+			background: #3c3c3c;
+			padding: 2px 6px;
+			border-radius: 3px;
+			margin: 0 3px;
+		}
+		.context-code {
+			color: #6a6a6a;
+		}
+		.completion-code {
+			color: #d4d4d4;
+		}
+	</style>
+</head>
+<body>
+	<div class="header">
+		<div>
+			<div class="breadcrumb">
+				<a href="../index.html">Home</a> / ${tc.category} / ${tc.name}
+			</div>
+			<h1>${tc.description}</h1>
+		</div>
+		<div class="nav">
+			<a href="${prevTest ? prevTest.name + ".html" : "#"}" class="${prevTest ? "" : "disabled"}" id="prev-link">← Previous</a>
+			<a href="../index.html">Index</a>
+			<a href="${nextTest ? nextTest.name + ".html" : "#"}" class="${nextTest ? "" : "disabled"}" id="next-link">Next →</a>
+		</div>
+	</div>
+
+	<div class="main-content">
+		<div class="panel" style="flex: 0.4;">
+			<h2>Input</h2>
+			<div class="meta-info">
+				<div class="meta-row">
+					<span class="meta-label">Filename:</span>
+					<span class="meta-value">${escapeHtml(tc.filename)}</span>
+				</div>
+				<div class="meta-row">
+					<span class="meta-label">Category:</span>
+					<span class="meta-value">${escapeHtml(tc.category)}</span>
+				</div>
+			</div>
+			<pre class="code-block">${highlightCursor(tc.input)}</pre>
+			${contextFilesHtml}
+		</div>
+		<div class="panel" style="flex: 0.6;">
+			<h2>Outputs (${tc.approvals.length} total)</h2>
+			${approvalsHtml}
+		</div>
+	</div>
+
+	<div class="keyboard-hint">
+		<kbd>←</kbd> Previous <kbd>→</kbd> Next <kbd>H</kbd> Home
+	</div>
+
+	<script>
+		document.addEventListener('keydown', (e) => {
+			if (e.key === 'ArrowLeft') {
+				const prev = document.getElementById('prev-link');
+				if (prev && !prev.classList.contains('disabled')) {
+					prev.click();
+				}
+			} else if (e.key === 'ArrowRight') {
+				const next = document.getElementById('next-link');
+				if (next && !next.classList.contains('disabled')) {
+					next.click();
+				}
+			} else if (e.key === 'h' || e.key === 'H') {
+				window.location.href = '../index.html';
+			}
+		});
+	</script>
+</body>
+</html>`
+}
+
+export async function generateHtmlReport(): Promise<void> {
+	console.log("\n📊 Generating HTML Report...\n")
+	console.log("Loading test cases...")
+
+	// Load approvals for each test case
+	const testCasesWithApprovals: TestCaseWithApprovals[] = testCases.map((tc) => ({
+		...tc,
+		approvals: loadApprovals(tc.category, tc.name),
+	}))
+
+	console.log(`Found ${testCasesWithApprovals.length} test cases`)
+
+	// Create output directory
+	if (fs.existsSync(OUTPUT_DIR)) {
+		fs.rmSync(OUTPUT_DIR, { recursive: true })
+	}
+	fs.mkdirSync(OUTPUT_DIR, { recursive: true })
+
+	// Generate index.html
+	console.log("Generating index.html...")
+	const indexHtml = generateIndexHtml(testCasesWithApprovals)
+	fs.writeFileSync(path.join(OUTPUT_DIR, "index.html"), indexHtml)
+
+	// Generate individual test case pages
+	const categories = new Set(testCasesWithApprovals.map((tc) => tc.category))
+	for (const category of categories) {
+		const categoryDir = path.join(OUTPUT_DIR, category)
+		fs.mkdirSync(categoryDir, { recursive: true })
+	}
+
+	for (const tc of testCasesWithApprovals) {
+		console.log(`Generating ${tc.category}/${tc.name}.html...`)
+		const html = generateTestCaseHtml(tc, testCasesWithApprovals)
+		fs.writeFileSync(path.join(OUTPUT_DIR, tc.category, `${tc.name}.html`), html)
+	}
+
+	console.log(`\n✅ Done! Generated ${testCasesWithApprovals.length + 1} HTML files in ${OUTPUT_DIR}`)
+	console.log(`\nOpen ${path.join(OUTPUT_DIR, "index.html")} in your browser to view the report.`)
+}

+ 2 - 1
src/test-llm-autocompletion/package.json

@@ -6,7 +6,8 @@
 	"scripts": {
 		"test": "tsx runner.ts",
 		"test:verbose": "tsx runner.ts --verbose",
-		"clean": "tsx runner.ts clean"
+		"clean": "tsx runner.ts clean",
+		"report": "tsx runner.ts report"
 	},
 	"dependencies": {
 		"@anthropic-ai/sdk": "^0.51.0",

+ 30 - 13
src/test-llm-autocompletion/runner.ts

@@ -2,9 +2,14 @@
 
 import fs from "fs"
 import path from "path"
+import { fileURLToPath } from "url"
 import { GhostProviderTester } from "./ghost-provider-tester.js"
 import { testCases, getCategories, TestCase } from "./test-cases.js"
 import { checkApproval } from "./approvals.js"
+import { generateHtmlReport } from "./html-report.js"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
 
 interface TestResult {
 	testCase: TestCase
@@ -18,7 +23,6 @@ interface TestResult {
 }
 
 export class TestRunner {
-	private tester: GhostProviderTester
 	private verbose: boolean
 	private results: TestResult[] = []
 	private skipApproval: boolean
@@ -30,7 +34,6 @@ export class TestRunner {
 		this.verbose = verbose
 		this.skipApproval = skipApproval
 		this.useOpusApproval = useOpusApproval
-		this.tester = new GhostProviderTester()
 		this.originalConsoleLog = console.log
 		this.originalConsoleInfo = console.info
 	}
@@ -47,11 +50,11 @@ export class TestRunner {
 		console.info = this.originalConsoleInfo
 	}
 
-	async runTest(testCase: TestCase): Promise<TestResult> {
+	async runTest(testCase: TestCase, tester: GhostProviderTester): Promise<TestResult> {
 		try {
 			this.suppressConsole()
 			const startTime = performance.now()
-			const { prefix, completion, suffix } = await this.tester.getCompletion(
+			const { prefix, completion, suffix } = await tester.getCompletion(
 				testCase.input,
 				testCase.name,
 				testCase.contextFiles,
@@ -110,8 +113,9 @@ export class TestRunner {
 	}
 
 	async runAllTests(numRuns: number = 1): Promise<void> {
+		const tester = new GhostProviderTester()
 		const model = process.env.LLM_MODEL || "mistralai/codestral-2508"
-		const strategyName = this.tester.getName()
+		const strategyName = tester.getName()
 
 		console.log("\n🚀 Starting LLM Autocompletion Tests\n")
 		console.log("Provider: kilocode")
@@ -145,7 +149,7 @@ export class TestRunner {
 
 				const runResults: TestResult[] = []
 				for (let run = 0; run < numRuns; run++) {
-					const result = await this.runTest(testCase)
+					const result = await this.runTest(testCase, tester)
 					result.strategyName = strategyName
 					runResults.push(result)
 					this.results.push(result)
@@ -224,7 +228,7 @@ export class TestRunner {
 			}
 		}
 
-		this.tester.dispose()
+		tester.dispose()
 		this.printSummary()
 	}
 
@@ -305,11 +309,13 @@ export class TestRunner {
 	}
 
 	async runSingleTest(testName: string, numRuns: number = 10): Promise<void> {
+		const tester = new GhostProviderTester()
 		const testCase = testCases.find((tc) => tc.name === testName)
 		if (!testCase) {
 			console.error(`Test "${testName}" not found`)
 			console.log("\nAvailable tests:")
 			testCases.forEach((tc) => console.log(`  - ${tc.name}`))
+			tester.dispose()
 			process.exit(1)
 		}
 
@@ -325,7 +331,7 @@ export class TestRunner {
 		for (let i = 0; i < numRuns; i++) {
 			console.log(`\n🔄 Run ${i + 1}/${numRuns}...`)
 
-			const result = await this.runTest(testCase)
+			const result = await this.runTest(testCase, tester)
 
 			results.push(result)
 
@@ -391,7 +397,7 @@ export class TestRunner {
 
 		console.log("\n" + "═".repeat(80) + "\n")
 
-		this.tester.dispose()
+		tester.dispose()
 		process.exit(passedRuns === numRuns ? 0 : 1)
 	}
 
@@ -468,11 +474,17 @@ async function main() {
 		}
 	}
 
-	const command = args.find((arg) => !arg.startsWith("-") && args.indexOf(arg) !== runsIndex + 1)
-
-	const runner = new TestRunner(verbose, skipApproval, useOpusApproval)
+	const command = args.find((arg, index) => !arg.startsWith("-") && (runsIndex === -1 || index !== runsIndex + 1))
 
 	try {
+		if (command === "report") {
+			await generateHtmlReport()
+			return
+		}
+
+		// Only create TestRunner for commands that need it
+		const runner = new TestRunner(verbose, skipApproval, useOpusApproval)
+
 		if (command === "clean") {
 			await runner.cleanApprovals()
 		} else if (command) {
@@ -503,5 +515,10 @@ function checkEnvironment() {
 	}
 }
 
-checkEnvironment()
+// Check if running a command that doesn't need API keys
+const argsForCheck = process.argv.slice(2)
+const commandForCheck = argsForCheck.find((arg) => !arg.startsWith("-"))
+if (commandForCheck !== "report" && commandForCheck !== "clean") {
+	checkEnvironment()
+}
 main().catch(console.error)

+ 48 - 4
webview-ui/src/App.tsx

@@ -17,6 +17,7 @@ import SettingsView, { SettingsViewRef } from "./components/settings/SettingsVie
 import WelcomeView from "./components/kilocode/welcome/WelcomeView" // kilocode_change
 import ProfileView from "./components/kilocode/profile/ProfileView" // kilocode_change
 import McpView from "./components/mcp/McpView" // kilocode_change
+import AuthView from "./components/kilocode/auth/AuthView" // kilocode_change
 import { MarketplaceView } from "./components/marketplace/MarketplaceView"
 import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
 import BottomControls from "./components/kilocode/BottomControls" // kilocode_change
@@ -32,7 +33,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
@@ -101,6 +102,9 @@ const App = () => {
 
 	const [showAnnouncement, setShowAnnouncement] = useState(false)
 	const [tab, setTab] = useState<Tab>("chat")
+	const [authReturnTo, setAuthReturnTo] = useState<"chat" | "settings">("chat")
+	const [authProfileName, setAuthProfileName] = useState<string | undefined>(undefined)
+	const [settingsEditingProfile, setSettingsEditingProfile] = useState<string | undefined>(undefined)
 
 	const [humanRelayDialogState, setHumanRelayDialogState] = useState<HumanRelayDialogState>({
 		isOpen: false,
@@ -138,7 +142,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)
@@ -168,6 +176,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
@@ -181,11 +202,27 @@ const App = () => {
 						(message.values?.section as string | undefined) ?? defaultSectionByAction[message.action]
 					// kilocode_change end
 					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
 					}
 				}
 			}
@@ -294,11 +331,18 @@ const App = () => {
 			{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
 			{/* kilocode_change end */}
 			{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
+			{/* kilocode_change: auth redirect / editingProfile */}
 			{tab === "settings" && (
-				<SettingsView ref={settingsRef} onDone={() => switchTab("chat")} targetSection={currentSection} /> // kilocode_change
+				<SettingsView
+					ref={settingsRef}
+					onDone={() => switchTab("chat")}
+					targetSection={currentSection}
+					editingProfile={settingsEditingProfile}
+				/>
 			)}
-			{/* kilocode_change: add profileview */}
+			{/* kilocode_change: add profileview and authview */}
 			{tab === "profile" && <ProfileView onDone={() => switchTab("chat")} />}
+			{tab === "auth" && <AuthView returnTo={authReturnTo} profileName={authProfileName} />}
 			{tab === "marketplace" && (
 				<MarketplaceView
 					stateManager={marketplaceStateManager}

+ 24 - 1
webview-ui/src/components/chat/ChatRow.tsx

@@ -76,6 +76,7 @@ import { InvalidModelWarning } from "../kilocode/chat/InvalidModelWarning"
 import { formatFileSize } from "@/lib/formatting-utils"
 import ChatTimestamps from "./ChatTimestamps"
 import { removeLeadingNonAlphanumeric } from "@/utils/removeLeadingNonAlphanumeric"
+import { KILOCODE_TOKEN_REQUIRED_ERROR } from "@roo/kilocode/errorUtils"
 // kilocode_change end
 
 // Helper function to get previous todos before a specific message
@@ -1388,7 +1389,29 @@ export const ChatRowContent = ({
 						</div>
 					)
 				case "error":
-					return <ErrorRow type="error" message={message.text || ""} />
+					// kilocode_change start: Show login button for KiloCode auth errors
+					const isKiloCodeAuthError =
+						apiConfiguration?.apiProvider === "kilocode" &&
+						message.text?.includes(KILOCODE_TOKEN_REQUIRED_ERROR)
+					return (
+						<ErrorRow
+							type="error"
+							message={message.text || ""}
+							showLoginButton={isKiloCodeAuthError}
+							onLoginClick={
+								isKiloCodeAuthError
+									? () => {
+											vscode.postMessage({
+												type: "switchTab",
+												tab: "auth",
+												values: { returnTo: "chat" },
+											})
+										}
+									: undefined
+							}
+						/>
+					)
+				// kilocode_change end
 				case "completion_result":
 					const commitRange = message.metadata?.kiloCode?.commitRange
 					return (

+ 50 - 5
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -20,6 +20,8 @@ import {
 	SearchResult,
 } from "@src/utils/context-mentions"
 import { convertToMentionPath } from "@/utils/path-mentions"
+import { escapeHtml } from "@/utils/highlight" // kilocode_change - FIM autocomplete
+import { useChatGhostText } from "./hooks/useChatGhostText" // kilocode_change: FIM autocomplete
 import { DropdownOptionType, Button, StandardTooltip } from "@/components/ui" // kilocode_change
 
 import Thumbnails from "../common/Thumbnails"
@@ -106,6 +108,17 @@ function handleSessionCommand(trimmedInput: string, setInputValue: (value: strin
 			setInputValue("")
 		}
 
+		return true
+	} else if (trimmedInput.startsWith("/session select ")) {
+		const sessionId = trimmedInput.substring("/session select ".length).trim()
+
+		vscode.postMessage({
+			type: "sessionSelect",
+			sessionId: sessionId,
+		})
+
+		setInputValue("")
+
 		return true
 	}
 
@@ -151,6 +164,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			globalWorkflows, // kilocode_change
 			taskHistoryVersion, // kilocode_change
 			clineMessages,
+			ghostServiceSettings, // kilocode_change
 		} = useExtensionState()
 
 		// kilocode_change start - autocomplete profile type system
@@ -303,6 +317,16 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 		const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
 		const [isFocused, setIsFocused] = useState(false)
 		const [imageWarning, setImageWarning] = useState<string | null>(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({
@@ -575,6 +599,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				// kilocode_change end
 				if (showContextMenu) {
 					if (event.key === "Escape") {
+						setShowContextMenu(false)
 						setSelectedType(null)
 						setSelectedMenuIndex(3) // File by default
 						return
@@ -638,6 +663,12 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					}
 				}
 
+				// 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
+
 				const isComposing = event.nativeEvent?.isComposing ?? false
 
 				// kilocode_change start
@@ -720,6 +751,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				handleSlashCommandsSelect,
 				selectedSlashCommandsIndex,
 				slashCommandsQuery,
+				handleGhostTextKeyDown, // kilocode_change: FIM autocomplete
 				// kilocode_change end
 				onSend,
 				showContextMenu,
@@ -765,7 +797,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				// 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 +880,14 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					setFileSearchResults([]) // Clear file search results.
 				}
 			},
-			[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
+			[
+				setInputValue,
+				setSearchRequestId,
+				setFileSearchResults,
+				setSearchLoading,
+				resetOnInputChange,
+				handleGhostTextInputChange, // kilocode_change: FIM autocomplete
+			],
 		)
 
 		useEffect(() => {
@@ -998,12 +1039,16 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 						processedText.substring(0, slashIndex) + highlighted + processedText.substring(endIndex)
 				}
 			}
-			// kilocode_change end
+			// kilocode_change start - autocomplete ghost text display
+			if (ghostText) {
+				processedText += `<span class="text-vscode-editor-foreground opacity-60 pointer-events-none">${escapeHtml(ghostText)}</span>`
+			}
+			// kilocode_change end - 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()
@@ -1676,7 +1721,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							// kilocode_change start
 							style={{
 								marginTop: "-38px",
-								zIndex: 2,
+								zIndex: 10,
 								paddingLeft: "8px",
 								paddingRight: "8px",
 								paddingBottom: isEditMode ? "10px" : "0",

+ 14 - 0
webview-ui/src/components/chat/ErrorRow.tsx

@@ -5,6 +5,7 @@ import { BookOpenText, MessageCircleWarning } from "lucide-react"
 import { useCopyToClipboard } from "@src/utils/clipboard"
 import { vscode } from "@src/utils/vscode"
 import CodeBlock from "../kilocode/common/CodeBlock" // kilocode_change
+import { Button } from "@src/components/ui" // kilocode_change
 
 /**
  * Unified error display component for all error types in the chat.
@@ -62,6 +63,8 @@ export interface ErrorRowProps {
 	messageClassName?: string
 	code?: number
 	docsURL?: string // NEW: Optional documentation link
+	showLoginButton?: boolean // kilocode_change
+	onLoginClick?: () => void // kilocode_change
 }
 
 /**
@@ -80,6 +83,8 @@ export const ErrorRow = memo(
 		messageClassName,
 		docsURL,
 		code,
+		showLoginButton = false, // kilocode_change
+		onLoginClick, // kilocode_change
 	}: ErrorRowProps) => {
 		const { t } = useTranslation()
 		const [isExpanded, setIsExpanded] = useState(defaultExpanded)
@@ -195,6 +200,15 @@ export const ErrorRow = memo(
 						}>
 						{message}
 					</p>
+					{/* kilocode_change start */}
+					{showLoginButton && onLoginClick && (
+						<div className="ml-6 mt-3">
+							<Button variant="secondary" onClick={onLoginClick}>
+								{t("kilocode:settings.provider.login")}
+							</Button>
+						</div>
+					)}
+					{/* kilocode_change end */}
 					{additionalContent}
 				</div>
 			</div>

+ 142 - 0
webview-ui/src/components/chat/__tests__/ChatRow.kilocode-auth-error.spec.tsx

@@ -0,0 +1,142 @@
+import React from "react"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { ChatRowContent } from "../ChatRow"
+import { vscode } from "@src/utils/vscode"
+
+// Create a variable to hold the mock state
+let mockExtensionState: any = {}
+
+// Mock ExtensionStateContext
+vi.mock("@src/context/ExtensionStateContext", () => ({
+	useExtensionState: () => mockExtensionState,
+}))
+
+// Mock i18n
+vi.mock("react-i18next", () => ({
+	useTranslation: () => ({
+		t: (key: string) => {
+			const map: Record<string, string> = {
+				"chat:error": "Error",
+				"kilocode:settings.provider.login": "Login",
+			}
+			return map[key] || key
+		},
+	}),
+	Trans: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
+	initReactI18next: { type: "3rdParty", init: () => {} },
+}))
+
+// Mock vscode postMessage
+vi.mock("@src/utils/vscode", () => ({
+	vscode: {
+		postMessage: vi.fn(),
+	},
+}))
+
+// Mock CodeBlock (avoid ESM/highlighter costs)
+vi.mock("@src/components/common/CodeBlock", () => ({
+	default: () => null,
+}))
+
+// Mock useSelectedModel hook
+vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({
+	useSelectedModel: () => ({ info: undefined }),
+}))
+
+const queryClient = new QueryClient()
+
+function renderChatRow(message: any, apiConfiguration: any = {}) {
+	// Update the mock state before rendering
+	mockExtensionState = {
+		apiConfiguration,
+		mcpServers: [],
+		alwaysAllowMcp: false,
+		currentCheckpoint: undefined,
+		mode: "code",
+		clineMessages: [],
+		showTimestamps: false,
+		hideCostBelowThreshold: 0,
+	}
+
+	return render(
+		<QueryClientProvider client={queryClient}>
+			<ChatRowContent
+				message={message}
+				isExpanded={false}
+				isLast={false}
+				isStreaming={false}
+				onToggleExpand={() => {}}
+				onSuggestionClick={() => {}}
+				onBatchFileResponse={() => {}}
+				onFollowUpUnmount={() => {}}
+				isFollowUpAnswered={false}
+			/>
+		</QueryClientProvider>,
+	)
+}
+
+describe("ChatRow - KiloCode auth error login button", () => {
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	it("shows login button for KiloCode auth error", () => {
+		const message: any = {
+			type: "say",
+			say: "error",
+			ts: Date.now(),
+			text: "Cannot complete request, make sure you are connected and logged in with the selected provider.\n\nKiloCode token + baseUrl is required to fetch models",
+		}
+
+		renderChatRow(message, { apiProvider: "kilocode" })
+
+		expect(screen.getByText("Login")).toBeInTheDocument()
+	})
+
+	it("does not show login button for non-KiloCode provider", () => {
+		const message: any = {
+			type: "say",
+			say: "error",
+			ts: Date.now(),
+			text: "Cannot complete request, make sure you are connected and logged in with the selected provider.\n\nKiloCode token + baseUrl is required to fetch models",
+		}
+
+		renderChatRow(message, { apiProvider: "openai" })
+
+		expect(screen.queryByText("Login")).not.toBeInTheDocument()
+	})
+
+	it("does not show login button for non-auth errors", () => {
+		const message: any = {
+			type: "say",
+			say: "error",
+			ts: Date.now(),
+			text: "Some other error message",
+		}
+
+		renderChatRow(message, { apiProvider: "kilocode" })
+
+		expect(screen.queryByText("Login")).not.toBeInTheDocument()
+	})
+
+	it("navigates to auth tab when login button is clicked", () => {
+		const message: any = {
+			type: "say",
+			say: "error",
+			ts: Date.now(),
+			text: "Cannot complete request, make sure you are connected and logged in with the selected provider.\n\nKiloCode token + baseUrl is required to fetch models",
+		}
+
+		renderChatRow(message, { apiProvider: "kilocode" })
+
+		const loginButton = screen.getByText("Login")
+		fireEvent.click(loginButton)
+
+		expect(vscode.postMessage).toHaveBeenCalledWith({
+			type: "switchTab",
+			tab: "auth",
+			values: { returnTo: "chat" },
+		})
+	})
+})

+ 122 - 0
webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx

@@ -0,0 +1,122 @@
+import { render, screen, fireEvent } from "@testing-library/react"
+import { describe, it, expect, vi } from "vitest"
+import { ErrorRow } from "../ErrorRow"
+
+// Mock react-i18next with initReactI18next for i18n setup
+vi.mock("react-i18next", () => ({
+	useTranslation: () => ({
+		t: (key: string) => {
+			const translations: Record<string, string> = {
+				"chat:error": "Error",
+				"chat:troubleMessage": "Trouble",
+				"chat:apiRequest.failed": "API Request Failed",
+				"chat:apiRequest.streamingFailed": "Streaming Failed",
+				"chat:apiRequest.cancelled": "Cancelled",
+				"chat:diffError.title": "Diff Error",
+				"kilocode:settings.provider.login": "Login",
+			}
+			return translations[key] || key
+		},
+	}),
+	initReactI18next: {
+		type: "3rdParty",
+		init: () => {},
+	},
+}))
+
+// Mock clipboard hook
+vi.mock("@src/utils/clipboard", () => ({
+	useCopyToClipboard: () => ({
+		copyWithFeedback: vi.fn().mockResolvedValue(true),
+	}),
+}))
+
+describe("ErrorRow", () => {
+	describe("basic rendering", () => {
+		it("renders error message", () => {
+			render(<ErrorRow type="error" message="Test error message" />)
+			expect(screen.getByText("Error")).toBeInTheDocument()
+			expect(screen.getByText("Test error message")).toBeInTheDocument()
+		})
+
+		it("renders custom title when provided", () => {
+			render(<ErrorRow type="error" title="Custom Title" message="Test message" />)
+			expect(screen.getByText("Custom Title")).toBeInTheDocument()
+		})
+
+		it("renders additional content when provided", () => {
+			render(
+				<ErrorRow
+					type="error"
+					message="Test message"
+					additionalContent={<div data-testid="additional">Additional content</div>}
+				/>,
+			)
+			expect(screen.getByTestId("additional")).toBeInTheDocument()
+		})
+	})
+
+	describe("login button", () => {
+		it("does not show login button by default", () => {
+			render(<ErrorRow type="error" message="Test error message" />)
+			expect(screen.queryByText("Login")).not.toBeInTheDocument()
+		})
+
+		it("shows login button when showLoginButton is true and onLoginClick is provided", () => {
+			const onLoginClick = vi.fn()
+			render(
+				<ErrorRow
+					type="error"
+					message="Test error message"
+					showLoginButton={true}
+					onLoginClick={onLoginClick}
+				/>,
+			)
+			expect(screen.getByText("Login")).toBeInTheDocument()
+		})
+
+		it("does not show login button when showLoginButton is true but onLoginClick is not provided", () => {
+			render(<ErrorRow type="error" message="Test error message" showLoginButton={true} />)
+			expect(screen.queryByText("Login")).not.toBeInTheDocument()
+		})
+
+		it("calls onLoginClick when login button is clicked", () => {
+			const onLoginClick = vi.fn()
+			render(
+				<ErrorRow
+					type="error"
+					message="Test error message"
+					showLoginButton={true}
+					onLoginClick={onLoginClick}
+				/>,
+			)
+
+			const loginButton = screen.getByText("Login")
+			fireEvent.click(loginButton)
+
+			expect(onLoginClick).toHaveBeenCalledTimes(1)
+		})
+	})
+
+	describe("error types", () => {
+		it("renders mistake_limit type with correct title", () => {
+			render(<ErrorRow type="mistake_limit" message="Test message" />)
+			expect(screen.getByText("Trouble")).toBeInTheDocument()
+		})
+
+		it("renders api_failure type with correct title", () => {
+			render(<ErrorRow type="api_failure" message="Test message" />)
+			expect(screen.getByText("API Request Failed")).toBeInTheDocument()
+		})
+
+		it("renders streaming_failed type with correct title", () => {
+			render(<ErrorRow type="streaming_failed" message="Test message" />)
+			expect(screen.getByText("Streaming Failed")).toBeInTheDocument()
+		})
+
+		it("renders cancelled type with correct title", () => {
+			render(<ErrorRow type="cancelled" message="Test message" />)
+			expect(screen.getByText("Cancelled")).toBeInTheDocument()
+		})
+	})
+})

+ 456 - 0
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<HTMLTextAreaElement>
+
+	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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			// 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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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<HTMLTextAreaElement>
+
+			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("")
+		})
+	})
+})

+ 174 - 0
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<HTMLTextAreaElement>
+	enableChatAutocomplete?: boolean
+}
+
+interface UseChatGhostTextReturn {
+	ghostText: string
+	handleKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => boolean // Returns true if event was handled
+	handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => 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<string>("")
+	const completionDebounceRef = useRef<NodeJS.Timeout | null>(null)
+	const completionRequestIdRef = useRef<string>("")
+	const skipNextCompletionRef = useRef<boolean>(false) // Skip completion after accepting suggestion
+
+	// Handle chat completion result messages
+	useEffect(() => {
+		const messageHandler = (event: MessageEvent<ExtensionMessage>) => {
+			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<HTMLTextAreaElement>): 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<HTMLTextAreaElement>) => {
+			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)
+				const requestId = generateRequestId()
+				completionRequestIdRef.current = requestId
+				completionDebounceRef.current = setTimeout(() => {
+					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)
+}

+ 129 - 0
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<AuthViewProps> = ({ returnTo = "chat", profileName }) => {
+	const [deviceAuthStatus, setDeviceAuthStatus] = useState<DeviceAuthStatus>("idle")
+	const [deviceAuthCode, setDeviceAuthCode] = useState<string>()
+	const [deviceAuthVerificationUrl, setDeviceAuthVerificationUrl] = useState<string>()
+	const [deviceAuthExpiresIn, setDeviceAuthExpiresIn] = useState<number>()
+	const [deviceAuthTimeRemaining, setDeviceAuthTimeRemaining] = useState<number>()
+	const [deviceAuthError, setDeviceAuthError] = useState<string>()
+	const [deviceAuthUserEmail, setDeviceAuthUserEmail] = useState<string>()
+
+	// 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 (
+		<Tab>
+			<TabContent className="flex flex-col items-center justify-center min-h-screen p-6">
+				<DeviceAuthCard
+					code={deviceAuthCode}
+					verificationUrl={deviceAuthVerificationUrl}
+					expiresIn={deviceAuthExpiresIn}
+					timeRemaining={deviceAuthTimeRemaining}
+					status={deviceAuthStatus}
+					error={deviceAuthError}
+					userEmail={deviceAuthUserEmail}
+					onCancel={handleCancelDeviceAuth}
+					onRetry={handleRetryDeviceAuth}
+				/>
+			</TabContent>
+		</Tab>
+	)
+}
+
+export default AuthView

+ 281 - 0
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 (
+		<div className="flex flex-col items-center gap-4 p-6 bg-vscode-sideBar-background rounded">
+			<Logo />
+			<div className="flex items-center gap-2">
+				<span className="codicon codicon-loading codicon-modifier-spin text-xl"></span>
+				<span className="text-vscode-foreground">{t("kilocode:deviceAuth.initiating")}</span>
+			</div>
+		</div>
+	)
+}
+
+// Inner component for success state
+interface SuccessStateProps {
+	userEmail?: string
+}
+
+const SuccessState: React.FC<SuccessStateProps> = ({ userEmail }) => {
+	const { t } = useAppTranslation()
+	return (
+		<div className="flex flex-col items-center gap-4 p-6 bg-vscode-sideBar-background rounded">
+			<Logo />
+			<h3 className="text-lg font-semibold text-vscode-foreground">{t("kilocode:deviceAuth.success")}</h3>
+			{userEmail && (
+				<p className="text-sm text-vscode-descriptionForeground">
+					{t("kilocode:deviceAuth.authenticatedAs", { email: userEmail })}
+				</p>
+			)}
+		</div>
+	)
+}
+
+// Inner component for error state
+interface ErrorStateProps {
+	error?: string
+	onRetry: () => void
+}
+
+const ErrorState: React.FC<ErrorStateProps> = ({ error, onRetry }) => {
+	const { t } = useAppTranslation()
+	return (
+		<div className="flex flex-col items-center gap-4 p-6 bg-vscode-sideBar-background rounded">
+			<Logo />
+			<h3 className="text-lg font-semibold text-vscode-foreground">{t("kilocode:deviceAuth.error")}</h3>
+			<p className="text-sm text-vscode-descriptionForeground text-center">
+				{error || t("kilocode:deviceAuth.unknownError")}
+			</p>
+			<ButtonPrimary onClick={onRetry}>{t("kilocode:deviceAuth.retry")}</ButtonPrimary>
+		</div>
+	)
+}
+
+// Inner component for cancelled state
+interface CancelledStateProps {
+	onRetry: () => void
+}
+
+const CancelledState: React.FC<CancelledStateProps> = ({ onRetry }) => {
+	const { t } = useAppTranslation()
+	return (
+		<div className="flex flex-col items-center gap-4 p-6 bg-vscode-sideBar-background rounded">
+			<Logo />
+			<h3 className="text-lg font-semibold text-vscode-foreground">{t("kilocode:deviceAuth.cancelled")}</h3>
+			<ButtonPrimary onClick={onRetry}>{t("kilocode:deviceAuth.tryAgain")}</ButtonPrimary>
+		</div>
+	)
+}
+
+// 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<PendingStateProps> = ({
+	code,
+	verificationUrl,
+	qrCodeDataUrl,
+	timeRemaining,
+	formatTime,
+	onOpenBrowser,
+	onCancel,
+}) => {
+	const { t } = useAppTranslation()
+	const handleCopyUrl = () => {
+		navigator.clipboard.writeText(verificationUrl)
+	}
+
+	return (
+		<div className="flex flex-col gap-2 bg-vscode-sideBar-background rounded">
+			<h3 className="text-lg font-semibold text-vscode-foreground text-center">
+				{t("kilocode:deviceAuth.title")}
+			</h3>
+
+			{/* Step 1: URL Section */}
+			<div className="flex flex-col gap-3">
+				<p className="text-sm text-vscode-descriptionForeground text-center">
+					{t("kilocode:deviceAuth.step1")}
+				</p>
+
+				{/* URL Box with Copy and Open Browser */}
+				<div className="flex flex-col gap-2">
+					<div className="flex items-center gap-2 p-3 bg-vscode-input-background border border-vscode-input-border rounded">
+						<span className="flex-1 text-sm font-mono text-vscode-foreground truncate">
+							{verificationUrl}
+						</span>
+						<button
+							onClick={handleCopyUrl}
+							className="flex-shrink-0 p-1 hover:bg-vscode-toolbar-hoverBackground rounded"
+							title={t("kilocode:deviceAuth.copyUrl")}>
+							<span className="codicon codicon-copy text-vscode-foreground"></span>
+						</button>
+					</div>
+					<ButtonPrimary onClick={onOpenBrowser}>{t("kilocode:deviceAuth.openBrowser")}</ButtonPrimary>
+				</div>
+
+				{/* QR Code Section */}
+				{qrCodeDataUrl && (
+					<div className="flex flex-col items-center gap-2 mt-2">
+						<p className="text-sm text-vscode-descriptionForeground">{t("kilocode:deviceAuth.scanQr")}</p>
+						<img
+							src={qrCodeDataUrl}
+							alt="QR Code"
+							className="w-40 h-40 border border-vscode-widget-border rounded"
+						/>
+					</div>
+				)}
+			</div>
+
+			{/* Step 2: Verification Section */}
+			<div className="flex flex-col gap-3">
+				<p className="text-sm text-vscode-descriptionForeground text-center">
+					{t("kilocode:deviceAuth.step2")}
+				</p>
+
+				{/* Verification Code */}
+				<div className="flex justify-center">
+					<div className="px-6 py-3 bg-vscode-input-background border-2 border-vscode-focusBorder rounded-lg">
+						<span className="text-2xl font-mono font-bold text-vscode-foreground tracking-wider">
+							{code}
+						</span>
+					</div>
+				</div>
+
+				{/* Time Remaining */}
+				<div className="flex items-center justify-center gap-2">
+					<span className="codicon codicon-clock text-vscode-descriptionForeground"></span>
+					<span className="text-sm text-vscode-descriptionForeground">
+						{t("kilocode:deviceAuth.timeRemaining", { time: formatTime(timeRemaining) })}
+					</span>
+				</div>
+
+				{/* Status */}
+				<div className="flex items-center justify-center gap-2">
+					<span className="codicon codicon-loading codicon-modifier-spin text-vscode-descriptionForeground"></span>
+					<span className="text-sm text-vscode-descriptionForeground">
+						{t("kilocode:deviceAuth.waiting")}
+					</span>
+				</div>
+			</div>
+
+			{/* Cancel Button */}
+			<div className="w-full flex flex-col">
+				<ButtonSecondary onClick={onCancel}>{t("kilocode:deviceAuth.cancel")}</ButtonSecondary>
+			</div>
+		</div>
+	)
+}
+
+const DeviceAuthCard: React.FC<DeviceAuthCardProps> = ({
+	code,
+	verificationUrl,
+	timeRemaining,
+	status,
+	error,
+	userEmail,
+	onCancel,
+	onRetry,
+}) => {
+	const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>("")
+
+	// 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 <InitiatingState />
+	}
+
+	if (status === "success") {
+		return <SuccessState userEmail={userEmail} />
+	}
+
+	if (status === "error") {
+		return <ErrorState error={error} onRetry={handleRetry} />
+	}
+
+	if (status === "cancelled") {
+		return <CancelledState onRetry={handleRetry} />
+	}
+
+	// Pending state - show code and QR
+	if (status === "pending" && code && verificationUrl) {
+		return (
+			<PendingState
+				code={code}
+				verificationUrl={verificationUrl}
+				qrCodeDataUrl={qrCodeDataUrl}
+				timeRemaining={timeRemaining}
+				formatTime={formatTime}
+				onOpenBrowser={handleOpenBrowser}
+				onCancel={handleCancel}
+			/>
+		)
+	}
+
+	// Idle state - shouldn't normally be shown
+	return null
+}
+
+export default DeviceAuthCard

+ 117 - 15
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<KiloCodeAuthProps> = ({ onManualConfigClick, className = "" }) => {
-	const { uriScheme, uiKind, kiloCodeWrapperProperties } = useExtensionState()
+type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled"
 
+const KiloCodeAuth: React.FC<KiloCodeAuthProps> = ({ onManualConfigClick, onLoginClick, className = "" }) => {
 	const { t } = useAppTranslation()
+	const [deviceAuthStatus, setDeviceAuthStatus] = useState<DeviceAuthStatus>("idle")
+	const [deviceAuthCode, setDeviceAuthCode] = useState<string>()
+	const [deviceAuthVerificationUrl, setDeviceAuthVerificationUrl] = useState<string>()
+	const [deviceAuthExpiresIn, setDeviceAuthExpiresIn] = useState<number>()
+	const [deviceAuthTimeRemaining, setDeviceAuthTimeRemaining] = useState<number>()
+	const [deviceAuthError, setDeviceAuthError] = useState<string>()
+	const [deviceAuthUserEmail, setDeviceAuthUserEmail] = useState<string>()
 
+	// 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 (
+			<div className={`flex flex-col items-center ${className}`}>
+				<DeviceAuthCard
+					code={deviceAuthCode}
+					verificationUrl={deviceAuthVerificationUrl}
+					expiresIn={deviceAuthExpiresIn}
+					timeRemaining={deviceAuthTimeRemaining}
+					status={deviceAuthStatus}
+					error={deviceAuthError}
+					userEmail={deviceAuthUserEmail}
+					onCancel={handleCancelDeviceAuth}
+					onRetry={handleRetryDeviceAuth}
+				/>
+			</div>
+		)
+	}
+
+	// Default welcome screen
 	return (
 		<div className={`flex flex-col items-center ${className}`}>
 			<Logo />
@@ -26,15 +136,7 @@ const KiloCodeAuth: React.FC<KiloCodeAuthProps> = ({ onManualConfigClick, classN
 			<p className="text-center mb-5">{t("kilocode:welcome.introText3")}</p>
 
 			<div className="w-full flex flex-col gap-5">
-				<ButtonLink
-					href={getKiloCodeBackendSignUpUrl(uriScheme, uiKind, kiloCodeWrapperProperties)}
-					onClick={() => {
-						if (uiKind === "Web" && onManualConfigClick) {
-							onManualConfigClick()
-						}
-					}}>
-					{t("kilocode:welcome.ctaButton")}
-				</ButtonLink>
+				<ButtonPrimary onClick={handleStartDeviceAuth}>{t("kilocode:welcome.ctaButton")}</ButtonPrimary>
 
 				{!!onManualConfigClick && (
 					<ButtonSecondary onClick={() => onManualConfigClick && onManualConfigClick()}>

+ 79 - 43
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<HTMLDivElement> & {
 	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 = ({
 						</div>
 					</div>
 
+					{!kiloCodeWrapperProperties?.kiloCodeWrapped && (
+						<>
+							<div className="flex flex-col gap-1">
+								<VSCodeCheckbox
+									checked={enableQuickInlineTaskKeybinding || false}
+									onChange={onEnableQuickInlineTaskKeybindingChange}>
+									<span className="font-medium">
+										{t("kilocode:ghost.settings.enableQuickInlineTaskKeybinding.label", {
+											keybinding: keybindings["kilo-code.addToContextAndFocus"],
+										})}
+									</span>
+								</VSCodeCheckbox>
+								<div className="text-vscode-descriptionForeground text-sm mt-1">
+									<Trans
+										i18nKey="kilocode:ghost.settings.enableQuickInlineTaskKeybinding.description"
+										components={{
+											DocsLink: (
+												<a
+													href="#"
+													onClick={() =>
+														openGlobalKeybindings("kilo-code.addToContextAndFocus")
+													}
+													className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"></a>
+											),
+										}}
+									/>
+								</div>
+							</div>
+							<div className="flex flex-col gap-1">
+								<VSCodeCheckbox
+									checked={enableSmartInlineTaskKeybinding || false}
+									onChange={onEnableSmartInlineTaskKeybindingChange}>
+									<span className="font-medium">
+										{t("kilocode:ghost.settings.enableSmartInlineTaskKeybinding.label", {
+											keybinding: keybindings["kilo-code.ghost.generateSuggestions"],
+										})}
+									</span>
+								</VSCodeCheckbox>
+								<div className="text-vscode-descriptionForeground text-sm mt-1">
+									<Trans
+										i18nKey="kilocode:ghost.settings.enableSmartInlineTaskKeybinding.description"
+										values={{ keybinding: keybindings["kilo-code.ghost.generateSuggestions"] }}
+										components={{
+											DocsLink: (
+												<a
+													href="#"
+													onClick={() =>
+														openGlobalKeybindings("kilo-code.ghost.generateSuggestions")
+													}
+													className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"></a>
+											),
+										}}
+									/>
+								</div>
+							</div>
+						</>
+					)}
+
 					<div className="flex flex-col gap-1">
 						<VSCodeCheckbox
-							checked={enableQuickInlineTaskKeybinding || false}
-							onChange={onEnableQuickInlineTaskKeybindingChange}>
-							<span className="font-medium">
-								{t("kilocode:ghost.settings.enableQuickInlineTaskKeybinding.label", {
-									keybinding: keybindings["kilo-code.addToContextAndFocus"],
-								})}
-							</span>
-						</VSCodeCheckbox>
-						<div className="text-vscode-descriptionForeground text-sm mt-1">
-							<Trans
-								i18nKey="kilocode:ghost.settings.enableQuickInlineTaskKeybinding.description"
-								components={{
-									DocsLink: (
-										<a
-											href="#"
-											onClick={() => openGlobalKeybindings("kilo-code.addToContextAndFocus")}
-											className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"></a>
-									),
-								}}
-							/>
-						</div>
-					</div>
-					<div className="flex flex-col gap-1">
-						<VSCodeCheckbox
-							checked={enableSmartInlineTaskKeybinding || false}
-							onChange={onEnableSmartInlineTaskKeybindingChange}>
+							checked={enableChatAutocomplete || false}
+							onChange={onEnableChatAutocompleteChange}>
 							<span className="font-medium">
-								{t("kilocode:ghost.settings.enableSmartInlineTaskKeybinding.label", {
-									keybinding: keybindings["kilo-code.ghost.generateSuggestions"],
-								})}
+								{t("kilocode:ghost.settings.enableChatAutocomplete.label")}
 							</span>
 						</VSCodeCheckbox>
 						<div className="text-vscode-descriptionForeground text-sm mt-1">
-							<Trans
-								i18nKey="kilocode:ghost.settings.enableSmartInlineTaskKeybinding.description"
-								values={{ keybinding: keybindings["kilo-code.ghost.generateSuggestions"] }}
-								components={{
-									DocsLink: (
-										<a
-											href="#"
-											onClick={() => openGlobalKeybindings("kilo-code.ghost.generateSuggestions")}
-											className="text-[var(--vscode-list-highlightForeground)] hover:underline cursor-pointer"></a>
-									),
-								}}
-							/>
+							<Trans i18nKey="kilocode:ghost.settings.enableChatAutocomplete.description" />
 						</div>
 					</div>
 

+ 20 - 0
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()
 

+ 9 - 12
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 = ({
 						</Button>
 					</div>
 				) : (
-					<VSCodeButtonLink
+					<Button
 						variant="secondary"
-						href={getKiloCodeBackendSignInUrl(uriScheme, uiKind, kiloCodeWrapperProperties)}>
+						onClick={() => {
+							vscode.postMessage({
+								type: "switchTab",
+								tab: "auth",
+								values: { returnTo: "settings", profileName: currentApiConfigName },
+							})
+						}}>
 						{t("kilocode:settings.provider.login")}
-					</VSCodeButtonLink>
+					</Button>
 				))}
 
 			<VSCodeTextField

+ 1 - 10
webview-ui/src/components/settings/ApiOptions.tsx

@@ -170,13 +170,7 @@ const ApiOptions = ({
 	currentApiConfigName, // kilocode_change
 }: ApiOptionsProps) => {
 	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}
 				/>
 			)}

+ 57 - 13
webview-ui/src/components/settings/SettingsView.tsx

@@ -126,19 +126,17 @@ type SectionName = (typeof sectionNames)[number] // kilocode_change
 type SettingsViewProps = {
 	onDone: () => void
 	targetSection?: string
+	editingProfile?: string // kilocode_change - profile to edit
 }
 
-const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, targetSection }, ref) => {
+// kilocode_change start - editingProfile
+const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>((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)
@@ -276,8 +274,26 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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)
@@ -728,7 +744,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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 },
@@ -739,7 +755,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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(() => {
@@ -748,6 +764,32 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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]
@@ -958,14 +1000,16 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 								/>
 								{/* kilocode_change end changes to allow for editting a non-active profile */}
 
+								{/* kilocode_change start - pass editing profile name */}
 								<ApiOptions
 									uriScheme={uriScheme}
 									apiConfiguration={apiConfiguration}
 									setApiConfigurationField={setApiConfigurationField}
 									errorMessage={errorMessage}
 									setErrorMessage={setErrorMessage}
-									currentApiConfigName={currentApiConfigName}
+									currentApiConfigName={editingApiConfigName}
 								/>
+								{/* kilocode_change end - pass editing profile name */}
 							</Section>
 						</div>
 					)}

+ 3 - 1
webview-ui/src/i18n/locales/ar/agentManager.json

@@ -33,7 +33,9 @@
 		"runningLocally": "يعمل محلياً",
 		"branch": "فرع",
 		"creatingSession": "جاري إنشاء الجلسة...",
-		"waitingForCli": "جارٍ تهيئة جلسة الوكيل..."
+		"waitingForCli": "جارٍ تهيئة جلسة الوكيل...",
+		"cancelCreating": "إلغاء إنشاء الجلسة",
+		"cancelButton": "إلغاء"
 	},
 	"messages": {
 		"waiting": "في انتظار رد الوكيل...",

+ 24 - 1
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": "جاري بدء المصادقة..."
 	}
 }

Some files were not shown because too many files changed in this diff