mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #8697 from Kilo-Org/acute-jingle
feat(jetbrains): lifecycle control, generated API client, dual HTTP clients, project-scoped RPC
This commit is contained in:
@@ -2,4 +2,15 @@
|
||||
build/
|
||||
.intellijPlatform/
|
||||
.ai/
|
||||
**/.kilo
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
.kotlin/
|
||||
bin/
|
||||
**/.classpath
|
||||
**/.project
|
||||
**/.settings/
|
||||
**/.kotlin/
|
||||
**/bin/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# AGENTS.md — Kilo JetBrains Plugin
|
||||
|
||||
## Architecture (Split Mode)
|
||||
|
||||
- **Split-mode plugin** with three Gradle modules: `shared/`, `frontend/`, `backend/`. The module descriptors are `kilo.jetbrains.shared.xml`, `kilo.jetbrains.frontend.xml`, `kilo.jetbrains.backend.xml` — these must stay in sync with `plugin.xml`'s `<content>` block.
|
||||
- Reference template for the split-mode structure: https://github.com/nicewith/intellij-platform-modular-plugin-template
|
||||
- Official docs: https://plugins.jetbrains.com/docs/intellij/split-mode-for-remote-development.html
|
||||
- The JetBrains reference template mirrors our overall structure well: root project assembles the final plugin, `shared` holds contracts, `frontend` holds UI, and `backend` holds project-local logic. Copy its split-mode wiring and RPC layout, but **do not** copy its Compose UI approach.
|
||||
- Kotlin source goes under `{module}/src/main/kotlin/ai/kilocode/jetbrains/`. Package name is `ai.kilocode.jetbrains` (matches `group` in root `build.gradle.kts`).
|
||||
- **Module placement rules**: backend modules host project model, indexing, analysis, execution, and CLI process management. Frontend modules host UI, typing assistance, and latency-sensitive features. Shared modules define RPC interfaces and data types used by both sides.
|
||||
- In monolithic IDE mode (non-remote), all three modules load in one process — split plugins work fine without remote dev.
|
||||
- Frontend ↔ backend communication uses RPC interfaces defined in `shared/`. Data sent over RPC must use `kotlinx.serialization`. In monolithic mode RPC is just an in-process suspend call.
|
||||
- **Testing split mode**: run `./gradlew generateSplitModeRunConfigurations` to create a "Run IDE (Split Mode)" config that starts both frontend and backend processes locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`).
|
||||
- The root `plugin.xml` is wiring only: keep plugin metadata and the `<content>` block there. Register services, extensions, listeners, and actions in the module XML descriptors, not in root `plugin.xml`.
|
||||
- Module descriptor files must live directly in `{module}/src/main/resources/`, not in `META-INF/`.
|
||||
- Module XMLs use `<dependencies>`, not `<depends>`. The allowed top-level registration tags are limited; keep module XMLs focused on `<resource-bundle>`, `<extensions>`, `<extensionPoints>`, `<actions>`, `<applicationListeners>`, and `<projectListeners>`.
|
||||
- Module dependencies determine where code loads. In monolith mode both frontend and backend dependencies are satisfied, so both modules load together.
|
||||
- Run inspection `Plugin DevKit | Code | Frontend and Backend API Usage` when adding or moving split-mode code.
|
||||
|
||||
## Split Feature Development
|
||||
|
||||
- For any new split feature, follow this flow: put UI in `frontend`, heavy/project-local logic in `backend`, and shared contracts in `shared`.
|
||||
- Shared cross-process payloads must be `@Serializable`. Keep `shared` lightweight and avoid pulling frontend-only or backend-only APIs into it.
|
||||
- Define RPC APIs in `shared` with `@Rpc`, `RemoteApi<Unit>`, and `suspend` methods only.
|
||||
- Implement RPC providers in `backend` and register them via `com.intellij.platform.rpc.backend.remoteApiProvider` when RPC is introduced.
|
||||
- Call RPC from `frontend` coroutines only. Never call RPC on the EDT; do not paper over this with blocking wrappers.
|
||||
- Wrap long-lived RPC calls and flows in `durable {}` so they survive reconnects and backend restarts.
|
||||
- For backend -> frontend push events, prefer Remote Topics over ad-hoc polling.
|
||||
- Render empty state immediately and progressively fill data from the backend. Do not block first paint on backend state.
|
||||
- Avoid chatty RPC. Debounce UI events, batch requests, cache results where appropriate, and page large datasets instead of sending everything at once.
|
||||
- If a new split feature requires RPC support similar to the JetBrains template, mirror the template's wiring: `shared` and `frontend` use the RPC/serialization plugins, and the backend adds the required backend RPC platform modules.
|
||||
|
||||
## CLI Integration
|
||||
|
||||
- CLI process spawning, extraction, and lifecycle belong in `backend`.
|
||||
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
|
||||
- Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`.
|
||||
- For packaging/build plumbing, see `script/build.ts` and `backend/build.gradle.kts`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Always bundle third-party libraries with the plugin.** Do not rely on libraries bundled with the IntelliJ platform (e.g. OkHttp, Gson, Guava, kotlinx-serialization-json). The IDE's bundled versions change across releases without notice and can cause version collisions, classloader conflicts, or silent API breakage. Declare all third-party dependencies as `implementation` in the relevant `build.gradle.kts` so they ship inside the plugin JAR and load from the plugin's own classloader.
|
||||
- `kotlinx.coroutines` is the one mandatory exception — it is provided by the platform and must not be bundled (the IntelliJ Platform Gradle plugin enforces this automatically).
|
||||
- Pin exact versions in `gradle/libs.versions.toml` and reference them via the version catalog (`libs.*`) in `build.gradle.kts`. Never hardcode version strings in `build.gradle.kts`.
|
||||
|
||||
## Services and Coroutines
|
||||
|
||||
- Official docs: https://plugins.jetbrains.com/docs/intellij/plugin-services.html and https://plugins.jetbrains.com/docs/intellij/launching-coroutines.html
|
||||
- **Prefer light services**: annotate with `@Service` (or `@Service(Service.Level.PROJECT)`) instead of registering in XML when the service won't be overridden or exposed as API. Light services must be `final` in Java (no `open` in Kotlin), cannot use constructor injection of other services, and don't support `os`/`client`/`overrides` attributes.
|
||||
- Non-light services that need XML registration go in `kilo.jetbrains.backend.xml` under `<extensions defaultExtensionNs="com.intellij"><applicationService>` (or `<projectService>`).
|
||||
- **Constructor-injected `CoroutineScope`**: the recommended way to launch coroutines. Each service gets its own scope (child of an intersection scope). The scope is cancelled on app/project shutdown or plugin unload. Supported signatures: `MyService(CoroutineScope)` for app services, `MyService(Project, CoroutineScope)` for project services.
|
||||
- The injected scope's context contains `Dispatchers.Default` and `CoroutineName(serviceClass)`. Switch to `Dispatchers.IO` for blocking I/O.
|
||||
- **Avoid heavy constructor work** — defer initialization to methods. Never cache service instances in fields; always retrieve via `service<T>()` at the call site.
|
||||
- `runBlockingCancellable` exists but is **not recommended** — use service scopes instead. For actions, use `currentThreadCoroutineScope()` which lets the Action System cancel the coroutine.
|
||||
- No extra coroutines dependency is needed — `kotlinx.coroutines` is bundled by the IntelliJ platform and available transitively.
|
||||
|
||||
## CLI Server Protocol
|
||||
|
||||
- The plugin spawns `kilo serve --port 0` (OS assigns random port) and reads stdout for `listening on http://...:(\d+)` to discover the port.
|
||||
- A random 32-byte hex password is passed via `KILO_SERVER_PASSWORD` env var for Basic Auth.
|
||||
- Key env vars: `KILO_CLIENT=jetbrains`, `KILO_PLATFORM=jetbrains`, `KILO_APP_NAME=kilo-code`, `KILO_ENABLE_QUESTION_TOOL=true`.
|
||||
- This is the same protocol used by the VS Code extension (`packages/kilo-vscode/src/services/cli-backend/server-manager.ts`).
|
||||
|
||||
## Build
|
||||
|
||||
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (builds CLI + Gradle plugin).
|
||||
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present).
|
||||
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
|
||||
- **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
|
||||
|
||||
## Files That Must Change Together
|
||||
|
||||
- `plugin.xml` `<content>` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`)
|
||||
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
|
||||
- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list
|
||||
|
||||
## UI Design Guidelines
|
||||
|
||||
Official references:
|
||||
|
||||
- [IntelliJ Platform UI Guidelines](https://jetbrains.design/intellij/)
|
||||
- [User Interface Components](https://plugins.jetbrains.com/docs/intellij/user-interface-components.html)
|
||||
- [UI FAQ (colors, borders, icons)](https://plugins.jetbrains.com/docs/intellij/ui-faq.html)
|
||||
|
||||
### Do Not Use Kotlin Compose
|
||||
|
||||
**Do not use Kotlin Compose or `intellij.platform.compose` in this plugin.** The JetBrains modular template uses Compose for its demo tool window, but Kilo should use standard Swing with IntelliJ Platform components only. Keep all plugin UI in the existing Swing-based stack.
|
||||
|
||||
### Do Not Use JCEF (Embedded Browser)
|
||||
|
||||
**Do not use JCEF (`JBCefBrowser`) in this plugin.** JCEF does not work in JetBrains remote development (split mode): the frontend process runs on the client machine but JCEF requires a display on the host, making it effectively unusable for remote users. Use standard Swing with IntelliJ Platform components for all UI.
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Need | API |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dialogs and settings pages with input fields bound to state | [Kotlin UI DSL v2](https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html) (`com.intellij.ui.dsl.builder`) |
|
||||
| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements (see below) |
|
||||
| Menus and toolbars | [Action System](https://plugins.jetbrains.com/docs/intellij/action-system.html) |
|
||||
|
||||
### Kotlin UI DSL v2
|
||||
|
||||
Use `panel { }` as the top-level builder — returns a `DialogPanel`. Structure: `panel` → `row` → cells (factory methods like `textField()`, `checkBox()`, `label()`). The DSL is for forms with bindings (`bindText`, `bindSelected`, etc.) and is **not** intended for general tool window UI.
|
||||
|
||||
```kotlin
|
||||
// Settings/dialog example
|
||||
panel {
|
||||
row("Label:") { textField().bindText(model::value) }
|
||||
group("Section") {
|
||||
row { checkBox("Enable feature").bindSelected(model::enabled) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns: `group {}` for titled sections, `indent {}` for left indent, `collapsibleGroup {}` for expandable sections, `buttonsGroup {}` for radio groups, `enabledIf()`/`visibleIf()` for reactive visibility, `.align(AlignX.FILL)` to stretch components.
|
||||
|
||||
To explore DSL capabilities interactively: **Tools → Internal Actions → UI → Kotlin UI DSL → UI DSL Showcase** (requires internal mode).
|
||||
|
||||
### Tool Windows
|
||||
|
||||
- Register declaratively in module XML via `com.intellij.toolWindow` extension point (already done in `kilo.jetbrains.frontend.xml`).
|
||||
- Implement `ToolWindowFactory.createToolWindowContent()` — called lazily on first click (zero overhead if unused).
|
||||
- Use `SimpleToolWindowPanel(vertical = true)` as a convenient base — supports toolbar + content layout.
|
||||
- Add tabs via `ToolWindow.contentManager`: create content with `ContentFactory.getInstance().createContent(component, title, isLockable)`, then `contentManager.addContent()`.
|
||||
- For conditional display, implement `ToolWindowFactory.isApplicableAsync(project)`.
|
||||
- Always use `ToolWindowManager.invokeLater()` instead of `Application.invokeLater()` for tool-window-related EDT tasks.
|
||||
|
||||
### Dialogs
|
||||
|
||||
- Extend `DialogWrapper`. Call `init()` from the constructor. Override `createCenterPanel()` to return UI content — prefer Kotlin UI DSL v2 for the panel contents.
|
||||
- Override `getPreferredFocusedComponent()` for initial focus, `getDimensionServiceKey()` for size persistence.
|
||||
- Show with `showAndGet()` (modal, returns boolean) or `show()` (then use `getExitCode()`).
|
||||
- Input validation: call `initValidation()` in constructor, override `doValidate()` → return `null` if valid or `ValidationInfo(message, component)` if not.
|
||||
|
||||
### Platform Components — Always Use Instead of Raw Swing
|
||||
|
||||
| Instead of | Use | Package |
|
||||
| --------------------- | ---------------------- | ------------------------------- |
|
||||
| `JLabel` | `JBLabel` | `com.intellij.ui.components` |
|
||||
| `JTextField` | `JBTextField` | `com.intellij.ui.components` |
|
||||
| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` |
|
||||
| `JList` | `JBList` | `com.intellij.ui.components` |
|
||||
| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` |
|
||||
| `JTable` | `JBTable` | `com.intellij.ui.table` |
|
||||
| `JTree` | `Tree` | `com.intellij.ui.treeStructure` |
|
||||
| `JSplitPane` | `JBSplitter` | `com.intellij.ui` |
|
||||
| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` |
|
||||
| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` |
|
||||
| `Color` | `JBColor` | `com.intellij.ui` |
|
||||
| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` |
|
||||
| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` |
|
||||
|
||||
Inspection `Plugin DevKit | Code | Undesirable class usage` highlights when you use raw Swing where a platform replacement exists.
|
||||
|
||||
### Multi-line and Rich Text
|
||||
|
||||
| Need | Component |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) |
|
||||
| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` |
|
||||
| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` |
|
||||
| High-perf colored text fragments (trees/lists/tables) | `SimpleColoredComponent` |
|
||||
| Plain-text newline splitting | `MultiLineLabel` — legacy, do not use in new code |
|
||||
|
||||
- Build HTML programmatically with `HtmlChunk`/`HtmlBuilder` (`com.intellij.openapi.util.text.HtmlChunk`). Avoid raw HTML string concatenation — it risks injection and breaks localization.
|
||||
- For simple wrapping/escaping: `XmlStringUtil.wrapInHtml(content)`, `XmlStringUtil.wrapInHtmlLines(lines...)`, `XmlStringUtil.escapeString(text)`.
|
||||
- Selectable/copyable label text: `JBLabel.setCopyable(true)` (switches internally to `JEditorPane` while preserving label appearance). Use `setAllowAutoWrapping(true)` for auto-wrap.
|
||||
- When creating a `JEditorPane` manually, always use `HTMLEditorKitBuilder` instead of constructing `HTMLEditorKit` directly: `editorPane.setEditorKit(HTMLEditorKitBuilder.simple())` or `.withWordWrapViewFactory().build()`.
|
||||
- Single-line overflow/ellipsis: use `SwingTextTrimmer` — do not manually truncate strings.
|
||||
- All user-visible strings go in `*.properties` files; HTML markup in values is acceptable.
|
||||
|
||||
### Colors and Theming
|
||||
|
||||
- **Never** use `java.awt.Color` directly. Use `JBColor(lightColor, darkColor)` or `JBColor.namedColor("key", fallback)` for theme-aware colors.
|
||||
- For lazy color retrieval (e.g. in painting), use `JBColor.lazy { UIManager.getColor("key") }`.
|
||||
- Check current theme: `JBColor.isBright()` returns `true` for light themes.
|
||||
- Generic UI colors: `UIUtil.getContextHelpForeground()`, `UIUtil.getLabelForeground()`, `UIUtil.getPanelBackground()`, etc.
|
||||
|
||||
### Borders, Insets, and Spacing
|
||||
|
||||
- Always create via `JBUI.Borders.empty(top, left, bottom, right)` and `JBUI.insets()` — DPI-aware and auto-update on zoom.
|
||||
- Use `JBUI.scale(int)` for any pixel dimension to ensure proper HiDPI scaling.
|
||||
|
||||
### Icons
|
||||
|
||||
- **Reuse platform icons**: browse at https://intellij-icons.jetbrains.design. Access via `AllIcons.*` constants.
|
||||
- Custom icons: SVG files in `resources/icons/`. Load via `IconLoader.getIcon("/icons/foo.svg", MyClass::class.java)`.
|
||||
- Organize in an `icons` package or a `*Icons` object with `@JvmField` on each constant.
|
||||
- **Sizing**: actions/nodes = 16×16, tool window = 13×13 (classic) or 20×20 + 16×16 compact (New UI), editor gutter = 12×12 (classic) / 14×14 (New UI).
|
||||
- **Dark variants**: `icon.svg` + `icon_dark.svg`. HiDPI: `icon@2x.svg` + `icon@2x_dark.svg`.
|
||||
- **New UI support**: place New UI icons in `expui/` directory, create `*IconMappings.json`, register via `com.intellij.iconMapper` extension point. New UI icon colors: light `#6C707E`, dark `#CED0D6`.
|
||||
|
||||
### Notifications
|
||||
|
||||
- Declare in module XML: `<notificationGroup id="Kilo Code" displayType="BALLOON"/>`.
|
||||
- Show: `Notification("Kilo Code", "message", NotificationType.INFORMATION).notify(project)`.
|
||||
- Add actions: `.addAction(NotificationAction.createSimpleExpiring("Label") { ... })`.
|
||||
- Sticky (user must dismiss): `displayType="STICKY_BALLOON"` + `.setSuggestionType(true)`.
|
||||
- Tool-window-bound: `displayType="TOOL_WINDOW" toolWindowId="Kilo Code"`.
|
||||
- Prefer non-modal notifications over `Messages.show*()` dialogs.
|
||||
|
||||
### Popups
|
||||
|
||||
- Use `JBPopupFactory.getInstance()` for lightweight floating UI (no chrome, auto-dismiss on focus loss).
|
||||
- `createComponentPopupBuilder(component, focusable)` for arbitrary Swing content; `createPopupChooserBuilder(list)` for item selection; `createActionGroupPopup()` for action menus.
|
||||
- Show with `showInBestPositionFor(editor)`, `showUnderneathOf(component)`, or `showInCenterOf(component)`.
|
||||
|
||||
### Lists and Trees
|
||||
|
||||
- `JBList` not `JList` — adds empty text, busy indicator, tooltip truncation.
|
||||
- `Tree` not `JTree` — adds wide selection painting, auto-scroll on DnD.
|
||||
- Custom renderers: `ColoredListCellRenderer` / `ColoredTreeCellRenderer` — `append()` for styled text, `setIcon()` for icons.
|
||||
- Speed search: `ListSpeedSearch(list)` / `TreeSpeedSearch(tree)`.
|
||||
- Editable list with add/remove/reorder toolbar: `ToolbarDecorator.createDecorator(list).setAddAction { }.setRemoveAction { }.createPanel()`.
|
||||
@@ -1,17 +1,110 @@
|
||||
plugins {
|
||||
alias(libs.plugins.rpc)
|
||||
alias(libs.plugins.kotlin)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.openapi.generator)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
resources.srcDir(layout.buildDirectory.dir("generated/cli"))
|
||||
kotlin.srcDir(generatedApi)
|
||||
}
|
||||
}
|
||||
|
||||
openApiGenerate {
|
||||
generatorName.set("kotlin")
|
||||
library.set("jvm-okhttp4")
|
||||
inputSpec.set("${rootDir}/../sdk/openapi.json")
|
||||
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
|
||||
packageName.set("ai.kilocode.jetbrains.api")
|
||||
apiPackage.set("ai.kilocode.jetbrains.api.client")
|
||||
modelPackage.set("ai.kilocode.jetbrains.api.model")
|
||||
configOptions.set(mapOf(
|
||||
"serializationLibrary" to "moshi",
|
||||
"omitGradleWrapper" to "true",
|
||||
"omitGradlePluginVersions" to "true",
|
||||
"useCoroutines" to "false",
|
||||
"sourceFolder" to "src/main/kotlin",
|
||||
"enumPropertyNaming" to "UPPERCASE",
|
||||
))
|
||||
// Remap schema "File" so the generated class is not named java.io.File
|
||||
modelNameMappings.set(mapOf(
|
||||
"File" to "DiffFileInfo",
|
||||
))
|
||||
// Map empty anyOf references to kotlin.Any
|
||||
typeMappings.set(mapOf(
|
||||
"AnyOfLessThanGreaterThan" to "kotlin.Any",
|
||||
"anyOf<>" to "kotlin.Any",
|
||||
))
|
||||
// Normalise OpenAPI 3.1 → 3.0-compatible patterns
|
||||
openapiNormalizer.set(mapOf(
|
||||
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
|
||||
"SIMPLIFY_ONEOF_ANYOF" to "true",
|
||||
))
|
||||
generateApiTests.set(false)
|
||||
generateModelTests.set(false)
|
||||
generateApiDocumentation.set(false)
|
||||
generateModelDocumentation.set(false)
|
||||
}
|
||||
|
||||
// Fix openapi-generator 3.1.1 codegen bugs in generated Kotlin sources.
|
||||
//
|
||||
// The OpenAPI spec uses `const: true` on boolean fields (e.g. `healthy`).
|
||||
// openapi-generator turns these into single-value enum classes:
|
||||
//
|
||||
// val healthy: GlobalHealth200Response.Healthy
|
||||
// enum class Healthy(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") }
|
||||
//
|
||||
// Moshi's EnumJsonAdapter calls nextString() for the value, but the server sends
|
||||
// a JSON boolean `true`, not a JSON string `"true"`, causing:
|
||||
// JsonDataException: Expected a string but was BOOLEAN at path $.healthy
|
||||
//
|
||||
// Fix: replace the enum field type with kotlin.Boolean, remove the enum class.
|
||||
val fixGeneratedApi by tasks.registering {
|
||||
dependsOn("openApiGenerate")
|
||||
val dir = generatedApi
|
||||
doLast {
|
||||
// Regex to find boolean const enum declarations inside data classes.
|
||||
// Captures the enum name so we can find and fix the corresponding field.
|
||||
val enumDecl = Regex(
|
||||
"""enum class (\w+)\(val value: kotlin\.Boolean\)"""
|
||||
)
|
||||
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
|
||||
var text = file.readText()
|
||||
val names = enumDecl.findAll(text).map { it.groupValues[1] }.toList()
|
||||
if (names.isEmpty()) return@forEach
|
||||
|
||||
for (name in names) {
|
||||
// Replace field type: `val foo: EnclosingClass.EnumName` → `val foo: kotlin.Boolean`
|
||||
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
|
||||
"${m.groupValues[1]}kotlin.Boolean"
|
||||
}
|
||||
// Remove the @JsonClass annotation + enum class block
|
||||
text = text.replace(Regex(
|
||||
"""\n\s*@JsonClass\(generateAdapter = false\)\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
|
||||
), "")
|
||||
// Remove the orphaned KDoc block that preceded the enum (lines of ` *` ending with `*/`)
|
||||
// These look like: \n /**\n * \n *\n * Values: TRUE\n */
|
||||
text = text.replace(Regex(
|
||||
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
|
||||
), "")
|
||||
}
|
||||
file.writeText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
}
|
||||
|
||||
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
|
||||
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
|
||||
|
||||
@@ -61,8 +154,14 @@ tasks.processResources {
|
||||
dependencies {
|
||||
intellijPlatform {
|
||||
intellijIdea(libs.versions.intellij.platform)
|
||||
bundledModule("intellij.platform.kernel.backend")
|
||||
bundledModule("intellij.platform.rpc.backend")
|
||||
bundledModule("intellij.platform.backend")
|
||||
}
|
||||
|
||||
implementation(project(":shared"))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
implementation(libs.moshi)
|
||||
implementation(libs.moshi.kotlin)
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import ai.kilocode.server.KiloProjectService
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.platform.project.ProjectId
|
||||
import com.intellij.platform.project.findProjectOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/**
|
||||
* Backend implementation of [KiloProjectRpcApi].
|
||||
*
|
||||
* Resolves the project from the [ProjectId] passed by the frontend
|
||||
* and delegates to the project-level [KiloProjectService].
|
||||
*/
|
||||
class KiloProjectRpcApiImpl : KiloProjectRpcApi {
|
||||
|
||||
private fun resolve(id: ProjectId): KiloProjectService {
|
||||
val project = id.findProjectOrNull()
|
||||
?: throw IllegalStateException("Project not found for id: $id")
|
||||
return project.service()
|
||||
}
|
||||
|
||||
override suspend fun connect(projectId: ProjectId) =
|
||||
resolve(projectId).connect()
|
||||
|
||||
override suspend fun state(projectId: ProjectId): Flow<ConnectionStateDto> =
|
||||
resolve(projectId).stream()
|
||||
|
||||
override suspend fun health(projectId: ProjectId): HealthDto =
|
||||
resolve(projectId).health()
|
||||
|
||||
override suspend fun restart(projectId: ProjectId) =
|
||||
resolve(projectId).restart()
|
||||
|
||||
override suspend fun reinstall(projectId: ProjectId) =
|
||||
resolve(projectId).reinstall()
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import com.intellij.platform.rpc.backend.RemoteApiProvider
|
||||
import fleet.rpc.remoteApiDescriptor
|
||||
|
||||
internal class KiloProjectRpcApiProvider : RemoteApiProvider {
|
||||
override fun RemoteApiProvider.Sink.remoteApis() {
|
||||
remoteApi(remoteApiDescriptor<KiloProjectRpcApi>()) {
|
||||
KiloProjectRpcApiImpl()
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
sealed class ConnectionState {
|
||||
data object Disconnected : ConnectionState()
|
||||
data object Connecting : ConnectionState()
|
||||
data class Connected(val port: Int, val password: String) : ConnectionState()
|
||||
data class Error(val message: String) : ConnectionState()
|
||||
}
|
||||
|
||||
data class SseEvent(val type: String, val data: String)
|
||||
|
||||
/**
|
||||
* App-level service managing the CLI server connection.
|
||||
*
|
||||
* Uses two separate OkHttp clients mirroring the VS Code architecture:
|
||||
* - [apiClient]: no call/read timeout — used for the generated API client and SSE
|
||||
* - [healthClient]: 3 s timeout — used only for `/global/health` polling
|
||||
*
|
||||
* The generated [DefaultApi] is configured with [apiClient] and exposed via [api]
|
||||
* for typed access to all CLI server endpoints.
|
||||
*/
|
||||
@Service(Service.Level.APP)
|
||||
class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloConnectionService::class.java)
|
||||
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
|
||||
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
|
||||
private const val RECONNECT_DELAY_MS = 250L
|
||||
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<SseEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<SseEvent> = _events.asSharedFlow()
|
||||
|
||||
/** Generated API client — null when disconnected. */
|
||||
var api: DefaultApi? = null
|
||||
private set
|
||||
|
||||
private var apiClient: OkHttpClient? = null
|
||||
private var healthClient: OkHttpClient? = null
|
||||
private var port = 0
|
||||
private var password = ""
|
||||
|
||||
private val source = AtomicReference<EventSource?>(null)
|
||||
private val lastEvent = AtomicLong(0L)
|
||||
private var heartbeatJob: Job? = null
|
||||
private var healthJob: Job? = null
|
||||
private var processJob: Job? = null
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
fun stream() = state.map(::dto).distinctUntilChanged()
|
||||
|
||||
suspend fun connect() {
|
||||
if (_state.value is ConnectionState.Connected || _state.value is ConnectionState.Connecting) return
|
||||
open()
|
||||
}
|
||||
|
||||
/** Kill the CLI process and restart it. Tears down all connections first. */
|
||||
suspend fun restart() {
|
||||
LOG.info("restart: initiated — tearing down current connection")
|
||||
teardown()
|
||||
LOG.info("restart: teardown complete — spawning new CLI process")
|
||||
open()
|
||||
LOG.info("restart: open() returned — CLI process started")
|
||||
}
|
||||
|
||||
/** Kill the CLI process, re-extract the binary from JAR, and restart. */
|
||||
suspend fun reinstall() {
|
||||
LOG.info("reinstall: initiated — tearing down current connection")
|
||||
teardown()
|
||||
LOG.info("reinstall: teardown complete — setting forceExtract flag")
|
||||
service<ServerManager>().forceExtract = true
|
||||
LOG.info("reinstall: spawning new CLI process (binary will be re-extracted)")
|
||||
open()
|
||||
LOG.info("reinstall: open() returned — CLI process started with fresh binary")
|
||||
}
|
||||
|
||||
/**
|
||||
* Full teardown: cancel all jobs, close SSE, shutdown HTTP clients, kill process.
|
||||
*
|
||||
* Order matters — reconnect/health/heartbeat jobs are cancelled first so they
|
||||
* cannot race with the SSE close or process kill.
|
||||
*/
|
||||
private suspend fun teardown() {
|
||||
LOG.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
|
||||
reconnectJob?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
healthJob?.cancel()
|
||||
processJob?.cancel()
|
||||
LOG.info("teardown: closing SSE event source")
|
||||
source.getAndSet(null)?.cancel()
|
||||
LOG.info("teardown: shutting down OkHttp clients")
|
||||
close()
|
||||
setState(ConnectionState.Disconnected)
|
||||
LOG.info("teardown: killing CLI process via ServerManager.stop()")
|
||||
service<ServerManager>().stop()
|
||||
LOG.info("teardown: complete")
|
||||
}
|
||||
|
||||
private suspend fun open() {
|
||||
source.getAndSet(null)?.cancel()
|
||||
close()
|
||||
processJob?.cancel()
|
||||
healthJob?.cancel()
|
||||
|
||||
setState(ConnectionState.Connecting)
|
||||
|
||||
val cli = service<ServerManager>()
|
||||
val result = cli.init()
|
||||
|
||||
if (result is ServerManager.ServerState.Error) {
|
||||
setState(ConnectionState.Error(result.message))
|
||||
return
|
||||
}
|
||||
|
||||
val ready = result as ServerManager.ServerState.Ready
|
||||
port = ready.port
|
||||
password = ready.password
|
||||
|
||||
// Create dual OkHttp clients (bundled — no IntelliJ platform deps)
|
||||
val ac = KiloHttpClients.api(password)
|
||||
val hc = KiloHttpClients.health(password)
|
||||
apiClient = ac
|
||||
healthClient = hc
|
||||
|
||||
// Configure generated API client with the no-timeout api client
|
||||
api = DefaultApi(basePath = "http://127.0.0.1:$port", client = ac)
|
||||
|
||||
startSse()
|
||||
startHeartbeatWatcher()
|
||||
healthJob = healthLoop()
|
||||
cli.process()?.let { proc ->
|
||||
processJob = monitorProcess(proc)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSse() {
|
||||
val http = apiClient ?: return
|
||||
val request = Request.Builder()
|
||||
.url("http://127.0.0.1:$port/global/event")
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
|
||||
val factory = EventSources.createFactory(
|
||||
http.newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
)
|
||||
source.set(factory.newEventSource(request, listener))
|
||||
LOG.info("SSE: connecting to port $port")
|
||||
}
|
||||
|
||||
private val listener = object : EventSourceListener() {
|
||||
override fun onOpen(src: EventSource, response: Response) {
|
||||
LOG.info("SSE: connected")
|
||||
setState(ConnectionState.Connected(port, password))
|
||||
lastEvent.set(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
override fun onEvent(src: EventSource, id: String?, type: String?, data: String) {
|
||||
lastEvent.set(System.currentTimeMillis())
|
||||
val kind = type ?: extractType(data)
|
||||
cs.launch { _events.emit(SseEvent(type = kind, data = data)) }
|
||||
}
|
||||
|
||||
override fun onClosed(src: EventSource) {
|
||||
LOG.info("SSE: stream closed — scheduling reconnect")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(src: EventSource, t: Throwable?, response: Response?) {
|
||||
if (t != null) {
|
||||
LOG.warn("SSE: failure (${t.message}) — scheduling reconnect")
|
||||
} else {
|
||||
LOG.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
|
||||
}
|
||||
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})"))
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = cs.launch {
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
if (!isActive) return@launch
|
||||
|
||||
val cli = service<ServerManager>()
|
||||
val proc = cli.process()
|
||||
|
||||
if (proc?.isAlive == true) {
|
||||
LOG.info("SSE: reconnecting")
|
||||
source.getAndSet(null)?.cancel()
|
||||
setState(ConnectionState.Connecting)
|
||||
startSse()
|
||||
return@launch
|
||||
}
|
||||
|
||||
LOG.warn("CLI process not running — restarting")
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHeartbeatWatcher() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = cs.launch {
|
||||
while (isActive) {
|
||||
delay(1_000)
|
||||
if (_state.value !is ConnectionState.Connected) continue
|
||||
val elapsed = System.currentTimeMillis() - lastEvent.get()
|
||||
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
||||
LOG.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
|
||||
source.getAndSet(null)?.cancel()
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun healthLoop() = cs.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
delay(HEALTH_POLL_INTERVAL_MS)
|
||||
if (_state.value !is ConnectionState.Connected) continue
|
||||
val ok = checkHealth()
|
||||
if (!ok && _state.value is ConnectionState.Connected) {
|
||||
LOG.warn("Health check failed — forcing SSE reconnect")
|
||||
source.getAndSet(null)?.cancel()
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkHealth(): Boolean {
|
||||
val http = healthClient ?: return false
|
||||
return try {
|
||||
val req = Request.Builder()
|
||||
.url("http://127.0.0.1:$port/global/health")
|
||||
.build()
|
||||
http.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
LOG.info("Health check exception: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun monitorProcess(proc: Process) = cs.launch(Dispatchers.IO) {
|
||||
proc.waitFor()
|
||||
service<ServerManager>().exited(proc)
|
||||
val code = proc.exitValue()
|
||||
LOG.warn("CLI process exited with code $code")
|
||||
source.getAndSet(null)?.cancel()
|
||||
setState(ConnectionState.Error("CLI process exited with code $code"))
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
api = null
|
||||
apiClient?.let { KiloHttpClients.shutdown(it) }
|
||||
apiClient = null
|
||||
healthClient?.let { KiloHttpClients.shutdown(it) }
|
||||
healthClient = null
|
||||
}
|
||||
|
||||
private fun setState(next: ConnectionState) {
|
||||
_state.value = next
|
||||
}
|
||||
|
||||
private fun dto(state: ConnectionState): ConnectionStateDto =
|
||||
when (state) {
|
||||
ConnectionState.Disconnected -> ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
|
||||
ConnectionState.Connecting -> ConnectionStateDto(ConnectionStatusDto.CONNECTING)
|
||||
is ConnectionState.Connected -> ConnectionStateDto(ConnectionStatusDto.CONNECTED)
|
||||
is ConnectionState.Error -> ConnectionStateDto(ConnectionStatusDto.ERROR, state.message)
|
||||
}
|
||||
|
||||
private fun extractType(data: String): String =
|
||||
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
|
||||
|
||||
override fun dispose() {
|
||||
source.getAndSet(null)?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
healthJob?.cancel()
|
||||
processJob?.cancel()
|
||||
reconnectJob?.cancel()
|
||||
close()
|
||||
setState(ConnectionState.Disconnected)
|
||||
LOG.info("KiloConnectionService disposed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Factory for the two OkHttp clients used by the plugin.
|
||||
*
|
||||
* Mirrors the VS Code architecture:
|
||||
* - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long)
|
||||
* - [health] client has a short 3 s timeout and a small dedicated connection pool
|
||||
*
|
||||
* Both clients bundle Basic Auth via an interceptor and are fully independent
|
||||
* of any IntelliJ-platform-provided HTTP stack.
|
||||
*/
|
||||
object KiloHttpClients {
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000L
|
||||
private const val HEALTH_TIMEOUT_MS = 3_000L
|
||||
|
||||
/** API client — no call/read timeout (SSE and long-running ops). */
|
||||
fun api(password: String): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(auth(password))
|
||||
.connectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Health client — short timeout, dedicated connection pool. */
|
||||
fun health(password: String): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(auth(password))
|
||||
.connectTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.connectionPool(ConnectionPool(1, 30, TimeUnit.SECONDS))
|
||||
.build()
|
||||
|
||||
/** Shut down both dispatcher and connection pool for the given client. */
|
||||
fun shutdown(client: OkHttpClient) {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private fun auth(password: String): Interceptor {
|
||||
val header = "Basic ${Base64.getEncoder().encodeToString("kilo:$password".toByteArray())}"
|
||||
return Interceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", header)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.project.Project
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Project-level backend service that delegates to the app-level
|
||||
* [KiloConnectionService] and scopes CLI API calls to this project's
|
||||
* working directory.
|
||||
*
|
||||
* The VS Code extension likewise scopes calls via `x-kilo-directory`.
|
||||
* In the JetBrains plugin this is achieved by passing the directory
|
||||
* parameter to each generated API method.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class KiloProjectService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloProjectService::class.java)
|
||||
}
|
||||
|
||||
private val connection: KiloConnectionService
|
||||
get() = service()
|
||||
|
||||
/** Project working directory sent as the `directory` parameter. */
|
||||
val directory: String
|
||||
get() = project.basePath ?: ""
|
||||
|
||||
/** Connection state (delegates to app-level service). */
|
||||
val state: StateFlow<ConnectionState>
|
||||
get() = connection.state
|
||||
|
||||
/** Connection state mapped to DTO for RPC transport. */
|
||||
fun stream() = connection.stream()
|
||||
|
||||
/** Ensure the CLI backend is running and connected. */
|
||||
suspend fun connect() = connection.connect()
|
||||
|
||||
/** Kill the CLI process and restart it. */
|
||||
suspend fun restart() = connection.restart()
|
||||
|
||||
/** Kill the CLI process, re-extract the binary, and restart. */
|
||||
suspend fun reinstall() = connection.reinstall()
|
||||
|
||||
/**
|
||||
* The generated API client, or null when disconnected.
|
||||
*
|
||||
* Callers should pass [directory] to each API method's `directory`
|
||||
* parameter to scope requests to this project.
|
||||
*/
|
||||
val api: DefaultApi?
|
||||
get() = connection.api
|
||||
|
||||
/**
|
||||
* One-shot health check via the generated API client.
|
||||
* Returns [HealthDto] or throws if not connected / server unreachable.
|
||||
*/
|
||||
suspend fun health(): HealthDto {
|
||||
val client = api
|
||||
if (client == null) {
|
||||
LOG.warn("health: API client is null — not connected")
|
||||
throw IllegalStateException("Not connected")
|
||||
}
|
||||
LOG.info("health: calling /global/health")
|
||||
val response = client.globalHealth()
|
||||
LOG.info("health: version=${response.version}")
|
||||
return HealthDto(healthy = true, version = response.version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Application-level service that manages the Kilo CLI binary lifecycle.
|
||||
*
|
||||
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
|
||||
* spawns `kilo serve --port 0`, and exposes the result as [ServerState].
|
||||
*
|
||||
* All concurrent callers of [init] share the same startup flow — only one
|
||||
* CLI process is ever spawned.
|
||||
*/
|
||||
@Service(Service.Level.APP)
|
||||
class ServerManager(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
sealed class ServerState {
|
||||
data class Ready(val port: Int, val password: String) : ServerState()
|
||||
data class Error(val message: String, val details: String? = null) :
|
||||
ServerState()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(ServerManager::class.java)
|
||||
private const val STARTUP_TIMEOUT_MS = 30_000L
|
||||
private const val KILL_TIMEOUT_SECONDS = 5L
|
||||
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var pending: Deferred<ServerState>? = null
|
||||
private var process: Process? = null
|
||||
private var hook: Thread? = null
|
||||
|
||||
/**
|
||||
* When true, the next [extractCli] call deletes and re-extracts the binary
|
||||
* regardless of the size check. Reset to false after extraction.
|
||||
*/
|
||||
@Volatile
|
||||
var forceExtract = false
|
||||
|
||||
fun process(): Process? = process
|
||||
|
||||
suspend fun init(): ServerState {
|
||||
val wait = mutex.withLock {
|
||||
val curr = pending
|
||||
if (curr != null && (!curr.isCompleted || process?.isAlive == true)) {
|
||||
return@withLock curr
|
||||
}
|
||||
|
||||
cs.async(Dispatchers.IO) { start() }.also {
|
||||
pending = it
|
||||
}
|
||||
}
|
||||
return wait.await()
|
||||
}
|
||||
|
||||
suspend fun exited(proc: Process) {
|
||||
mutex.withLock {
|
||||
if (process != proc) return@withLock
|
||||
process = null
|
||||
pending = null
|
||||
uninstall()
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill the running CLI process and reset state so the next [init] spawns fresh. */
|
||||
suspend fun stop() {
|
||||
mutex.withLock {
|
||||
val proc = process ?: return@withLock
|
||||
process = null
|
||||
pending = null
|
||||
uninstall()
|
||||
kill(proc, "stop()")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun start(): ServerState {
|
||||
return try {
|
||||
val path = extractCli()
|
||||
LOG.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
|
||||
withTimeout(STARTUP_TIMEOUT_MS) {
|
||||
spawn(path)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("CLI startup failed", e)
|
||||
ServerState.Error(
|
||||
message = e.message ?: "Unknown error",
|
||||
details = e.stackTraceToString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractCli(): File {
|
||||
val platform = platform()
|
||||
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
val resource = "cli/$platform/$exe"
|
||||
val loader = javaClass.classLoader
|
||||
|
||||
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
|
||||
|
||||
if (forceExtract && target.exists()) {
|
||||
LOG.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
|
||||
target.delete()
|
||||
forceExtract = false
|
||||
}
|
||||
|
||||
val url = loader.getResource(resource)
|
||||
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
|
||||
|
||||
val size = url.openConnection().contentLengthLong
|
||||
if (size >= 0 && target.exists() && target.length() == size) {
|
||||
LOG.info("CLI binary up-to-date at ${target.absolutePath}")
|
||||
return target
|
||||
}
|
||||
|
||||
LOG.info("Extracting CLI binary to ${target.absolutePath}")
|
||||
target.parentFile.mkdirs()
|
||||
|
||||
url.openStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (!SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
private suspend fun spawn(cli: File): ServerState =
|
||||
withContext(Dispatchers.IO) {
|
||||
val pwd = generatePassword()
|
||||
|
||||
val env = buildMap {
|
||||
putAll(System.getenv())
|
||||
put("KILO_SERVER_PASSWORD", pwd)
|
||||
put("KILO_CLIENT", "jetbrains")
|
||||
put("KILO_ENABLE_QUESTION_TOOL", "true")
|
||||
put("KILO_PLATFORM", "jetbrains")
|
||||
put("KILO_APP_NAME", "kilo-code")
|
||||
}
|
||||
|
||||
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
|
||||
val builder = ProcessBuilder(cmd)
|
||||
builder.environment().clear()
|
||||
builder.environment().putAll(env)
|
||||
builder.redirectErrorStream(false)
|
||||
|
||||
LOG.info("Starting CLI: ${cmd.joinToString(" ")}")
|
||||
LOG.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
|
||||
val proc = builder.start()
|
||||
LOG.info("CLI process started (pid=${proc.pid()})")
|
||||
process = proc
|
||||
install(proc)
|
||||
|
||||
val stderr = StringBuilder()
|
||||
|
||||
Thread({
|
||||
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
|
||||
reader.lineSequence().forEach { line ->
|
||||
LOG.warn("CLI stderr: $line")
|
||||
synchronized(stderr) { stderr.appendLine(line) }
|
||||
}
|
||||
}
|
||||
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
|
||||
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
|
||||
for (line in reader.lineSequence()) {
|
||||
LOG.info("CLI stdout: $line")
|
||||
val match = PORT_REGEX.find(line)
|
||||
if (match != null) {
|
||||
val p = match.groupValues[1].toInt()
|
||||
LOG.info("CLI server ready on port $p")
|
||||
return@withContext ServerState.Ready(port = p, password = pwd)
|
||||
}
|
||||
|
||||
if (!proc.isAlive) break
|
||||
}
|
||||
}
|
||||
|
||||
val code = proc.waitFor()
|
||||
val details = synchronized(stderr) { stderr.toString().trim() }
|
||||
process = null
|
||||
uninstall()
|
||||
ServerState.Error(
|
||||
message = "CLI process exited with code $code before announcing a port",
|
||||
details = details.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
pending = null
|
||||
uninstall()
|
||||
|
||||
kill(proc, "Disposing")
|
||||
}
|
||||
|
||||
private fun install(proc: Process) {
|
||||
uninstall()
|
||||
|
||||
val next = Thread({
|
||||
LOG.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
|
||||
kill(proc, "Shutdown hook", wait = false)
|
||||
}, "kilo-cli-shutdown")
|
||||
|
||||
val ok = runCatching {
|
||||
Runtime.getRuntime().addShutdownHook(next)
|
||||
}
|
||||
|
||||
if (ok.isFailure) {
|
||||
LOG.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
|
||||
return
|
||||
}
|
||||
|
||||
hook = next
|
||||
}
|
||||
|
||||
private fun uninstall() {
|
||||
val curr = hook ?: return
|
||||
hook = null
|
||||
|
||||
val ok = runCatching {
|
||||
Runtime.getRuntime().removeShutdownHook(curr)
|
||||
}
|
||||
|
||||
if (ok.isFailure) {
|
||||
LOG.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun kill(proc: Process, source: String, wait: Boolean = true) {
|
||||
LOG.info("$source — killing CLI process tree (pid ${proc.pid()})")
|
||||
children(proc).forEach { it.destroy() }
|
||||
proc.destroy()
|
||||
|
||||
if (!wait) return
|
||||
|
||||
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
LOG.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
|
||||
children(proc).forEach { it.destroyForcibly() }
|
||||
proc.destroyForcibly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun children(proc: Process): List<ProcessHandle> {
|
||||
return proc.toHandle().descendants().toList().asReversed()
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException(
|
||||
"Unsupported OS: ${
|
||||
System.getProperty(
|
||||
"os.name"
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
|
||||
private fun generatePassword(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
<idea-plugin>
|
||||
<dependencies>
|
||||
<module name="intellij.platform.backend"/>
|
||||
<module name="intellij.platform.kernel.backend"/>
|
||||
<module name="kilo.jetbrains.shared"/>
|
||||
</dependencies>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.rpc.KiloProjectRpcApiProvider"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.rpc)
|
||||
alias(libs.plugins.kotlin)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
package ai.kilocode
|
||||
|
||||
import ai.kilocode.rpc.KiloProjectRpcApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.platform.project.projectId
|
||||
import fleet.rpc.client.durable
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Frontend project-level service for Kilo CLI interaction.
|
||||
*
|
||||
* Communicates with the backend via [KiloProjectRpcApi], passing
|
||||
* [project.projectId] on every call so the backend can resolve the
|
||||
* correct project-level service without scanning ProjectManager.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class KiloApiService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloApiService::class.java)
|
||||
private val init = ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
|
||||
}
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** CLI version string from the last successful health check, or null if unknown. */
|
||||
@Volatile
|
||||
var version: String? = null
|
||||
private set
|
||||
|
||||
val state: StateFlow<ConnectionStateDto> = flow {
|
||||
durable {
|
||||
KiloProjectRpcApi.getInstance()
|
||||
.state(project.projectId())
|
||||
.collect { emit(it) }
|
||||
}
|
||||
}.stateIn(cs, SharingStarted.Eagerly, init)
|
||||
|
||||
fun connect() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
cs.launch {
|
||||
durable {
|
||||
KiloProjectRpcApi.getInstance().connect(project.projectId())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot health check. Returns null on failure. */
|
||||
suspend fun health(): HealthDto? = try {
|
||||
durable { KiloProjectRpcApi.getInstance().health(project.projectId()) }
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("health check failed", e)
|
||||
null
|
||||
}
|
||||
|
||||
/** Kill the CLI process and restart it. */
|
||||
suspend fun restart() {
|
||||
LOG.info("restart: resetting state and sending RPC")
|
||||
started.set(false)
|
||||
version = null
|
||||
durable { KiloProjectRpcApi.getInstance().restart(project.projectId()) }
|
||||
LOG.info("restart: RPC returned — backend restart complete")
|
||||
}
|
||||
|
||||
/** Kill the CLI process, re-extract the binary, and restart. */
|
||||
suspend fun reinstall() {
|
||||
LOG.info("reinstall: resetting state and sending RPC")
|
||||
started.set(false)
|
||||
version = null
|
||||
durable { KiloProjectRpcApi.getInstance().reinstall(project.projectId()) }
|
||||
LOG.info("reinstall: RPC returned — backend reinstall complete")
|
||||
}
|
||||
|
||||
/** Fire-and-forget restart from non-suspend context (e.g. action handlers). */
|
||||
fun restartAsync() {
|
||||
LOG.info("restartAsync: launching restart")
|
||||
cs.launch { restart() }
|
||||
}
|
||||
|
||||
/** Fire-and-forget reinstall from non-suspend context (e.g. action handlers). */
|
||||
fun reinstallAsync() {
|
||||
LOG.info("reinstallAsync: launching reinstall")
|
||||
cs.launch { reinstall() }
|
||||
}
|
||||
|
||||
/** Fetch the CLI version and cache it. Call once after connection is established. */
|
||||
fun fetchVersionAsync() {
|
||||
cs.launch {
|
||||
LOG.info("fetchVersion: requesting health check")
|
||||
val dto = health()
|
||||
if (dto == null) {
|
||||
LOG.warn("fetchVersion: health check returned null — version not available")
|
||||
return@launch
|
||||
}
|
||||
version = dto.version
|
||||
LOG.info("fetchVersion: CLI version is ${dto.version}")
|
||||
}
|
||||
}
|
||||
|
||||
fun watch(fn: (String) -> Unit): Job {
|
||||
val mgr = ToolWindowManager.getInstance(project)
|
||||
return cs.launch {
|
||||
state.collect { next ->
|
||||
// Fetch CLI version when we become connected
|
||||
if (next.status == ConnectionStatusDto.CONNECTED) fetchVersionAsync()
|
||||
mgr.invokeLater {
|
||||
fn(text(next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun text(state: ConnectionStateDto): String =
|
||||
when (state.status) {
|
||||
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
|
||||
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
|
||||
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected")
|
||||
ConnectionStatusDto.ERROR -> KiloBundle.message(
|
||||
"toolwindow.status.error",
|
||||
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ai.kilocode
|
||||
|
||||
import com.intellij.DynamicBundle
|
||||
import org.jetbrains.annotations.PropertyKey
|
||||
|
||||
private const val BUNDLE = "messages.KiloBundle"
|
||||
|
||||
object KiloBundle : DynamicBundle(BUNDLE) {
|
||||
fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String {
|
||||
return getMessage(key, *params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ai.kilocode
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.content.ContentFactory
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.GridBagConstraints
|
||||
import java.awt.GridBagLayout
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
|
||||
class KiloToolWindowFactory : ToolWindowFactory {
|
||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||
val svc = project.service<KiloApiService>()
|
||||
val icon = JBLabel(
|
||||
IconLoader.getIcon("/icons/kilo-content.svg", KiloToolWindowFactory::class.java),
|
||||
).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
alignmentX = JPanel.CENTER_ALIGNMENT
|
||||
}
|
||||
|
||||
val text = JBLabel(KiloBundle.message("toolwindow.status.disconnected"), SwingConstants.CENTER).apply {
|
||||
alignmentX = JPanel.CENTER_ALIGNMENT
|
||||
font = JBUI.Fonts.label(13f)
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
setAllowAutoWrapping(true)
|
||||
}
|
||||
|
||||
val body = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
add(icon)
|
||||
add(Box.createVerticalStrut(JBUI.scale(16)))
|
||||
add(text)
|
||||
}
|
||||
|
||||
val panel = JPanel(GridBagLayout()).apply {
|
||||
isOpaque = false
|
||||
add(body, GridBagConstraints())
|
||||
}
|
||||
|
||||
val content = ContentFactory.getInstance().createContent(panel, "", false)
|
||||
val ui = Disposer.newDisposable()
|
||||
val job = svc.watch { msg ->
|
||||
text.text = msg
|
||||
}
|
||||
Disposer.register(ui, Disposable { job.cancel() })
|
||||
content.setDisposer(ui)
|
||||
toolWindow.contentManager.addContent(content)
|
||||
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
|
||||
toolWindow.setTitleActions(listOf(it))
|
||||
}
|
||||
svc.connect()
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package ai.kilocode.actions
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionGroup
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
|
||||
/**
|
||||
* Gear icon action placed in the Kilo tool window title bar.
|
||||
*
|
||||
* Looks up [Kilo.SettingsGroup] from [ActionManager] and shows it
|
||||
* as a popup. The group composition is declared in
|
||||
* `kilo.jetbrains.frontend.xml`.
|
||||
*/
|
||||
class KiloSettingsAction : AnAction() {
|
||||
|
||||
companion object {
|
||||
const val GROUP_ID = "Kilo.SettingsGroup"
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val component = e.inputEvent?.component ?: return
|
||||
val group = ActionManager.getInstance().getAction(GROUP_ID) as? ActionGroup ?: return
|
||||
|
||||
JBPopupFactory.getInstance()
|
||||
.createActionGroupPopup(
|
||||
null,
|
||||
group,
|
||||
e.dataContext,
|
||||
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
|
||||
true, // showDisabledActions — StatusInfoAction is always disabled
|
||||
)
|
||||
.showUnderneathOf(component)
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package ai.kilocode.actions
|
||||
|
||||
import ai.kilocode.KiloApiService
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
|
||||
class ReinstallKiloAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
e.project?.service<KiloApiService>()?.reinstallAsync()
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val state = e.project?.service<KiloApiService>()?.state?.value
|
||||
e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package ai.kilocode.actions
|
||||
|
||||
import ai.kilocode.KiloApiService
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
|
||||
class RestartKiloAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
e.project?.service<KiloApiService>()?.restartAsync()
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val state = e.project?.service<KiloApiService>()?.state?.value
|
||||
e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package ai.kilocode.actions
|
||||
|
||||
import ai.kilocode.KiloApiService
|
||||
import ai.kilocode.KiloBundle
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
|
||||
/**
|
||||
* Non-interactive info row at the bottom of the settings popup showing
|
||||
* connection status and CLI version (from the last health check).
|
||||
*/
|
||||
class StatusInfoAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
// intentionally non-actionable
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val svc = e.project?.service<KiloApiService>() ?: return
|
||||
val status = when (svc.state.value.status) {
|
||||
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected.short")
|
||||
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
|
||||
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
|
||||
ConnectionStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
|
||||
}
|
||||
val ver = svc.version?.let { " · $it" } ?: ""
|
||||
e.presentation.text = "$status$ver"
|
||||
e.presentation.isEnabled = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M512 0H0V512H512V0Z" fill="black"/>
|
||||
<path d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z" fill="#FAF74F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M512 0H0V512H512V0Z" fill="black"/>
|
||||
<path d="M512 512H0V0H512V512ZM322.783 322.784H278.261V392.747L308.472 422.958H378.435V378.437H322.782L322.783 322.784ZM422.957 308.474L392.746 278.263H322.783V322.784H378.435L378.435 378.437H422.957L422.957 308.474ZM233.739 278.263H189.217V322.784H233.739V278.263ZM89.0435 392.747L119.254 422.958H233.739V378.437H133.565V278.263H89.043L89.0435 392.747ZM372.538 189.217V119.254L342.327 89.0435H278.261V133.565H328.017V189.217H278.261V233.739H422.957V189.217H372.538ZM133.565 89.0435H89.0435V233.739H133.565V183.652H189.218V233.739H233.74V183.652L189.218 139.13H133.565V89.0435ZM233.739 89.0435H189.217L189.218 139.13H233.739V89.0435Z" fill="#FAF74F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 810 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H0V0H16V16ZM10.09 10.09H8.7V12.27L9.64 13.22H11.83V11.83H10.09L10.09 10.09ZM13.22 9.64L12.27 8.7H10.09V10.09H11.83L11.83 11.83H13.22L13.22 9.64ZM7.3 8.7H5.91V10.09H7.3V8.7ZM2.78 12.27L3.73 13.22H7.3V11.83H4.17V8.7H2.78L2.78 12.27ZM11.64 5.91V3.73L10.7 2.78H8.7V4.17H10.25V5.91H8.7V7.3H13.22V5.91H11.64ZM4.17 2.78H2.78V7.3H4.17V5.74H5.91V7.3H7.3V5.74L5.91 4.35H4.17V2.78ZM7.3 2.78H5.91L5.91 4.35H7.3V2.78Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 20H0V0H20V20ZM12.61 12.61H10.87V15.34L12.05 16.52H14.78V14.78H12.61L12.61 12.61ZM16.52 12.05L15.34 10.87H12.61V12.61H14.78L14.78 14.78H16.52L16.52 12.05ZM9.13 10.87H7.39V12.61H9.13V10.87ZM3.48 15.34L4.66 16.52H9.13V14.78H5.22V10.87H3.48L3.48 15.34ZM14.55 7.39V4.66L13.37 3.48H10.87V5.22H12.81V7.39H10.87V9.13H16.52V7.39H14.55ZM5.22 3.48H3.48V9.13H5.22V7.17H7.39V9.13H9.13V7.17L7.39 5.43H5.22V3.48ZM9.13 3.48H7.39L7.39 5.43H9.13V3.48Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 20H0V0H20V20ZM12.61 12.61H10.87V15.34L12.05 16.52H14.78V14.78H12.61L12.61 12.61ZM16.52 12.05L15.34 10.87H12.61V12.61H14.78L14.78 14.78H16.52L16.52 12.05ZM9.13 10.87H7.39V12.61H9.13V10.87ZM3.48 15.34L4.66 16.52H9.13V14.78H5.22V10.87H3.48L3.48 15.34ZM14.55 7.39V4.66L13.37 3.48H10.87V5.22H12.81V7.39H10.87V9.13H16.52V7.39H14.55ZM5.22 3.48H3.48V9.13H5.22V7.17H7.39V9.13H9.13V7.17L7.39 5.43H5.22V3.48ZM9.13 3.48H7.39L7.39 5.43H9.13V3.48Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H0V0H16V16ZM10.09 10.09H8.7V12.27L9.64 13.22H11.83V11.83H10.09L10.09 10.09ZM13.22 9.64L12.27 8.7H10.09V10.09H11.83L11.83 11.83H13.22L13.22 9.64ZM7.3 8.7H5.91V10.09H7.3V8.7ZM2.78 12.27L3.73 13.22H7.3V11.83H4.17V8.7H2.78L2.78 12.27ZM11.64 5.91V3.73L10.7 2.78H8.7V4.17H10.25V5.91H8.7V7.3H13.22V5.91H11.64ZM4.17 2.78H2.78V7.3H4.17V5.74H5.91V7.3H7.3V5.74L5.91 4.35H4.17V2.78ZM7.3 2.78H5.91L5.91 4.35H7.3V2.78Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -3,4 +3,35 @@
|
||||
<module name="intellij.platform.frontend"/>
|
||||
<module name="kilo.jetbrains.shared"/>
|
||||
</dependencies>
|
||||
|
||||
<resource-bundle>messages.KiloBundle</resource-bundle>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<toolWindow id="Kilo Code"
|
||||
anchor="left"
|
||||
icon="/icons/kilo.svg"
|
||||
factoryClass="ai.kilocode.KiloToolWindowFactory"/>
|
||||
</extensions>
|
||||
|
||||
<actions>
|
||||
<action id="Kilo.Restart"
|
||||
class="ai.kilocode.actions.RestartKiloAction"/>
|
||||
|
||||
<action id="Kilo.Reinstall"
|
||||
class="ai.kilocode.actions.ReinstallKiloAction"/>
|
||||
|
||||
<action id="Kilo.StatusInfo"
|
||||
class="ai.kilocode.actions.StatusInfoAction"/>
|
||||
|
||||
<group id="Kilo.SettingsGroup">
|
||||
<reference ref="Kilo.Restart"/>
|
||||
<reference ref="Kilo.Reinstall"/>
|
||||
<separator/>
|
||||
<reference ref="Kilo.StatusInfo"/>
|
||||
</group>
|
||||
|
||||
<action id="Kilo.Settings"
|
||||
class="ai.kilocode.actions.KiloSettingsAction"
|
||||
icon="AllIcons.General.GearPlain"/>
|
||||
</actions>
|
||||
</idea-plugin>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
toolwindow.status.disconnected=Status: Disconnected
|
||||
toolwindow.status.connecting=Status: Connecting...
|
||||
toolwindow.status.connected=Status: Connected
|
||||
toolwindow.status.error=Status: Error - {0}
|
||||
toolwindow.error.unknown=Unknown error
|
||||
|
||||
toolwindow.status.connected.short=Connected
|
||||
toolwindow.status.connecting.short=Connecting\u2026
|
||||
toolwindow.status.disconnected.short=Disconnected
|
||||
toolwindow.status.error.short=Error
|
||||
|
||||
action.Kilo.Settings.text=Settings
|
||||
action.Kilo.Settings.description=Kilo Code settings
|
||||
action.Kilo.SettingsGroup.text=Settings
|
||||
action.Kilo.SettingsGroup.description=Kilo Code settings
|
||||
action.Kilo.Restart.text=Restart Kilo
|
||||
action.Kilo.Restart.description=Kill and restart the CLI process
|
||||
action.Kilo.Reinstall.text=Reinstall Kilo
|
||||
action.Kilo.Reinstall.description=Re-extract the CLI binary and restart
|
||||
@@ -1,12 +1,24 @@
|
||||
[versions]
|
||||
intellij-platform = "2025.3"
|
||||
intellij-gradle-plugin = "2.10.5"
|
||||
intellij-rpc-plugin = "2.1.20-0.1"
|
||||
kotlin-jvm-plugin = "2.1.20"
|
||||
kotlin-serialization-plugin = "2.1.20"
|
||||
kotlin-serialization = "1.7.3"
|
||||
okhttp = "4.12.0"
|
||||
moshi = "1.15.1"
|
||||
openapi-generator = "7.12.0"
|
||||
|
||||
[libraries]
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
|
||||
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
|
||||
|
||||
[plugins]
|
||||
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }
|
||||
rpc = { id = "rpc", version.ref = "intellij-rpc-plugin" }
|
||||
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" }
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-jvm-plugin" }
|
||||
openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.rpc)
|
||||
alias(libs.plugins.kotlin)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.platform.project.ProjectId
|
||||
import com.intellij.platform.rpc.RemoteApiProviderService
|
||||
import fleet.rpc.RemoteApi
|
||||
import fleet.rpc.Rpc
|
||||
import fleet.rpc.remoteApiDescriptor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Project-scoped RPC API exposed from backend to frontend.
|
||||
*
|
||||
* Every method takes a [ProjectId] as its first parameter, following the
|
||||
* JetBrains modular plugin template pattern. The frontend obtains the ID
|
||||
* via `project.projectId()` and the backend resolves the project via
|
||||
* `projectId.findProjectOrNull()`.
|
||||
*/
|
||||
@Rpc
|
||||
interface KiloProjectRpcApi : RemoteApi<Unit> {
|
||||
companion object {
|
||||
suspend fun getInstance(): KiloProjectRpcApi {
|
||||
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloProjectRpcApi>())
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the CLI backend is running and connected. */
|
||||
suspend fun connect(projectId: ProjectId)
|
||||
|
||||
/** Observe connection state changes. */
|
||||
suspend fun state(projectId: ProjectId): Flow<ConnectionStateDto>
|
||||
|
||||
/** One-shot health check against /global/health. */
|
||||
suspend fun health(projectId: ProjectId): HealthDto
|
||||
|
||||
/** Kill the CLI process and restart it. */
|
||||
suspend fun restart(projectId: ProjectId)
|
||||
|
||||
/** Kill the CLI process, re-extract the binary, and restart. */
|
||||
suspend fun reinstall(projectId: ProjectId)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ConnectionStatusDto {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionStateDto(
|
||||
val status: ConnectionStatusDto,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class HealthDto(
|
||||
val healthy: Boolean,
|
||||
val version: String,
|
||||
)
|
||||
Reference in New Issue
Block a user