Merge remote-tracking branch 'origin/main' into prairie-crocus

# Conflicts:
#	packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
This commit is contained in:
kirillk
2026-05-24 13:33:21 -04:00
360 changed files with 22545 additions and 4454 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show detected ports for tracked background processes in the TUI sidebar and process detail dialog.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support tracked background processes in the CLI and VS Code so agents can start long-running dev servers and clean them up when sessions change or end. The CLI also includes process management UI, status, and logs.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve the JetBrains model picker with sections, favorites, and smart filtering.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Use the fallback logo in old Windows terminal emulators while keeping the Unicode logo available over SSH.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add a JetBrains tool window action for starting new sessions and let recent sessions reopen in the side panel without duplicate views.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support typed custom responses to question prompts in the JetBrains plugin.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Scale JetBrains chat transcript fonts from the editor font settings.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep the JetBrains model picker at the same row position when models are favorited or unfavorited.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix JetBrains startup when command templates are returned as lazy objects.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains session connection status inline above the prompt, cap expanded connection details, and keep empty sessions in the shared chat scroll area.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show detailed JetBrains session connection and configuration issues in the connection panel, and make retry recover app-side problems more reliably.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Match VS Code-style reasoning and tool run rendering in JetBrains chat.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Center description text and remove the fixed-width cap from the empty session panel so recent sessions can stretch naturally.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open existing JetBrains sessions scrolled to the latest message after history loads.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Remember the JetBrains session header metrics expansion preference and show file names in read/write timeline tooltips.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains history navigation with Back support and a Show History action in recent sessions.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve the JetBrains history panel with native tabs, independent local and cloud search, and date-grouped cloud sessions.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show the logged-out account status in the same rounded panel as the logged-in account overlay, with a "Not logged in" label, hidden picker/balance, and a profile icon to open settings.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add a Dismiss button to the paid-model sign-in prompt so users can close it and choose a different model.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains sign-in UI: step labels are left-aligned, the URL field selects all on click, copying the URL or device code shows a confirmation balloon, and the click-to-copy code card and balance card share the same themed background and border.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve the JetBrains mode picker with descriptions, deprecated badges, and native IntelliJ selection behavior.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a sign-in prompt in JetBrains sessions when a paid model requires login.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains permission prompts with compact action rows and diff badges.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Polish the JetBrains user profile settings layout with a compact account stack, copyable email, simplified organization names, and a refreshable balance card.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add native Kilo profile settings page to JetBrains plugin. Settings > Tools > Kilo > User Profile shows login/logout, balance, personal/org account switching, and a dashboard link, and refreshes immediately after login, logout, or active account changes. A new Profile button in the tool window toolbar opens the page directly. The profile settings page now keeps its native UI mounted while login state and active account details change.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains question prompts one question at a time with aligned native option rows.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep the JetBrains chat transcript pinned to bottom reliably while responses stream.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a scroll-to-bottom button when JetBrains chat sessions are scrolled away from the latest message.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render active question and permission prompts inside the scrollable JetBrains chat transcript.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support rebinding the JetBrains prompt Send action in the IntelliJ keymap.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show account login, switching, and balance controls on the empty JetBrains session screen.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show collapsible session metadata, context usage, token usage, and compaction controls in the JetBrains session header.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add a native session history panel to the JetBrains plugin.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Control JetBrains chat transcript spacing from the session layout.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Reduce redundant JetBrains chat scroll layout work while keeping bottom-follow behavior.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show completed assistant steps in the JetBrains session timeline.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support stopping active JetBrains sessions from the prompt action button.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Avoid leaving mouse and advanced keyboard modes enabled after exiting the TUI in mintty and MINGW terminals.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Size the JetBrains model picker from its rendered model rows instead of fixed popup dimensions.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support reasoning effort selection and resetting JetBrains chat model overrides to the default model.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Align checked and unchecked reasoning picker rows in the JetBrains plugin.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Start expandable session sections collapsed by default.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve question-based session views so UI text uses editor-sized interface fonts, actions align consistently, and permission prompts show a header icon.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep the JetBrains session connection status above the prompt without shifting the chat layout.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Refine JetBrains session loading UI and settings actions.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Preserve JetBrains chat scroll position when reviewing earlier session output.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Avoid flickering transient loading and connection error states in JetBrains sessions.
+1
View File
@@ -34,6 +34,7 @@ a.out
target
.scripts
.direnv/
packages/kilo-jetbrains/build-tasks/.gradle/
# Local dev files
opencode-dev
+1 -1
View File
@@ -1 +1 @@
v1.14.34
v1.14.41
+104 -19
View File
@@ -33,7 +33,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.3.7",
"version": "7.3.8",
"bin": {
"opencode": "./bin/opencode",
},
@@ -68,7 +68,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -98,7 +98,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -134,7 +134,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.3.7",
"version": "7.3.8",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -144,7 +144,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -176,10 +176,11 @@
},
"packages/kilo-jetbrains": {
"name": "@kilocode/kilo-jetbrains",
"version": "7.3.8",
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -193,7 +194,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -230,7 +231,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -293,7 +294,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.3.7",
"version": "7.3.8",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -301,7 +302,7 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
@@ -451,7 +452,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -476,7 +477,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"semver": "^7.6.3",
},
@@ -487,7 +488,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -502,7 +503,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.3.7",
"version": "7.3.8",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -525,7 +526,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.3.7",
"version": "7.3.8",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -626,7 +627,7 @@
"@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/node": "24.12.2",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20260316.1",
"ai": "6.0.168",
@@ -672,7 +673,7 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
@@ -2164,7 +2165,7 @@
"@types/mssql": ["@types/mssql@9.1.11", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-vcujgrDbDezCxNDO4KY6gjwduLYOKfrexpRUwhoysRvcXZ3+IgZ/PMYFDgh8c3cQIxZ6skAwYo+H6ibMrBWPjQ=="],
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
@@ -4338,7 +4339,7 @@
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
@@ -4552,6 +4553,8 @@
"@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -4656,6 +4659,8 @@
"@kilocode/kilo-indexing/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@manypkg/find-root/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
@@ -4804,6 +4809,30 @@
"@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@types/cacache/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/cross-spawn/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/mssql/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/node-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/npm-registry-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/npmcli__arborist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/npmlog/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/pacote/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/qrcode/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/readable-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/ssri/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/ws/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@vscode/ripgrep/yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
@@ -4840,6 +4869,8 @@
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
"apache-arrow/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -4874,6 +4905,8 @@
"buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"bun-types/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
@@ -4966,6 +4999,8 @@
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"image-q/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"isomorphic-git/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -5088,6 +5123,8 @@
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"protobufjs/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
@@ -5154,6 +5191,8 @@
"tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
@@ -5212,6 +5251,8 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@@ -5250,10 +5291,14 @@
"@kilocode/kilo-gateway/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"@manypkg/find-root/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@morphllm/morphsdk/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"@morphllm/morphsdk/openai/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@morphllm/morphsdk/openai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -5360,6 +5405,30 @@
"@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@types/cacache/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/cross-spawn/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/mssql/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/npm-registry-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/npmcli__arborist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/npmlog/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/pacote/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/qrcode/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/readable-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/ssri/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/ws/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@vscode/ripgrep/yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"@vscode/test-cli/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -5402,6 +5471,8 @@
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"apache-arrow/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -5432,6 +5503,8 @@
"bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"bun-types/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"c8/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -5536,10 +5609,14 @@
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"image-q/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"kilo-code/openai/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
@@ -5602,6 +5679,8 @@
"posthog-js/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ=="],
"protobufjs/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -5632,6 +5711,8 @@
"tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"test-exclude/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"test-exclude/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -5664,6 +5745,8 @@
"@morphllm/morphsdk/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@morphllm/morphsdk/openai/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@octokit/graphql/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
@@ -5776,6 +5859,8 @@
"gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"kilo-code/openai/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"mocha/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"mocha/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-1GI3/cmf3ne+dxfEHzp7EzvEO0GzjgXtlilFGzV73hc=",
"aarch64-linux": "sha256-lHO7dmc1inyzFjAgoU7hyNUHldcrWOFu6eeBL1NaB14=",
"aarch64-darwin": "sha256-9j9ZdIZ6TD9F1GioHSRynaPjNHENdrl2sgMI93P2QhA=",
"x86_64-darwin": "sha256-3qKN9xzg2TzhnxX/zhwtP5V0152d5bfnn3hlctv8IJI="
"x86_64-linux": "sha256-EbXkvlbexL1Gyy4vvGG5j+JWjv7SsVk6T5YnDfo3AQY=",
"aarch64-linux": "sha256-+MuJE4XGr0dArD/HuozaoK9Oymfgvx1CisE/Sm4ZstY=",
"aarch64-darwin": "sha256-Qdk+tZLydGld471ApL1cxfd85QQNFelTfqq7uAznfK4=",
"x86_64-darwin": "sha256-Fo6W65MfAcXaulBGCaE7+GfHtq3VVJyVDJpIENvR7lQ="
}
}
+2 -2
View File
@@ -36,7 +36,7 @@
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/node": "24.12.2",
"@types/semver": "7.7.1",
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
@@ -145,6 +145,6 @@
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
},
"version": "7.3.7",
"version": "7.3.8",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.7",
"version": "7.3.8",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2
View File
@@ -87,6 +87,8 @@ export const layer = Layer.effect(
Effect.sync(() => Service.of(make())),
)
export const defaultLayer = layer
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.3.7"
version = "7.3.8"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.7/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.8/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.7/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.8/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.7/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.8/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.7/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.8/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.7/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.8/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.3.7",
"version": "7.3.8",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18d394d726ebee312e54db5d45f55cca3a1728d2b41f9f73bb9b610096bf53e0
size 41004
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.3.7",
"version": "7.3.8",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.3.7",
"version": "7.3.8",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-indexing",
"version": "7.3.7",
"version": "7.3.8",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+34
View File
@@ -171,6 +171,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first).
- **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.
- **Run split backend**: `./gradlew runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting.
- **Test split mode**: `./gradlew generateSplitModeRunConfigurations` creates 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`).
## UI Guidelines
@@ -365,6 +366,39 @@ For common spacing lookups, prefer `JBUI.CurrentTheme` area-specific insets (e.g
| Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` |
| Simple vertical custom Swing groups | `VerticalLayout` |
| Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` |
| Single-component alignment wrapper | `ai.kilocode.client.ui.layout.Align` — see section below |
### Align — Single-Component Alignment Wrapper
Use `Align` (`ai.kilocode.client.ui.layout.Align`) when a single Swing component must be positioned inside available space without adding visual chrome. It is a transparent, no-border, no-color `JPanel(null)` that lays out its one child according to independent horizontal (`HAlign`) and vertical (`VAlign`) modes. `CenterShrinkPanel` has been removed; use `child.align(HAlign.CENTER, VAlign.CENTER)` as a direct replacement.
**Alignment modes:**
| Mode | Axis | Layout behavior | Wrapper size contribution |
|---|---|---|---|
| `HAlign.TRACK` / `VAlign.TRACK` | either | Child always fills all available space; ignores child min/preferred/max | Zero (wrapper reports insets only on that axis) |
| `HAlign.FIT` / `VAlign.FIT` | either | Child fills available space clamped to child's effective `[min, max]` range | Child min/preferred/max respected |
| `HAlign.LEFT` / `VAlign.TOP` | H / V | Child placed at left/top edge at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected |
| `HAlign.CENTER` / `VAlign.CENTER` | H / V | Child centered at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected |
| `HAlign.RIGHT` / `VAlign.BOTTOM` | H / V | Child placed at right/bottom edge at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected |
"Bounded preferred" means the child's preferred size coerced into the effective `[min, max]` range. If available space is smaller than the effective minimum, the layout shrinks the child to available space to avoid overflow.
**Factory extension** on `Component`:
```kotlin
child.align(HAlign.LEFT, VAlign.TOP) // left-aligned, top-pinned
child.align(HAlign.CENTER, VAlign.CENTER) // centered (replaces CenterShrinkPanel)
child.align(HAlign.TRACK, VAlign.CENTER) // fill width, center vertically
child.align(HAlign.TRACK, VAlign.TRACK) // fill all available space
```
**Rules:**
- Prefer `child.align(h, v)` over creating one-off `JPanel(FlowLayout(...))` or `BorderLayoutPanel` wrappers just to control alignment.
- Use `TRACK` when the child must occupy all available space on an axis and must not reserve any space in the parent's size negotiation on that axis. Use `FIT` when you want to fill available space but still respect child min/max constraints.
- All non-TRACK modes include the child's min, preferred, and max sizes in the wrapper's own min/preferred/max size. This means parent layout managers see the child constraints through the wrapper.
- Do not use `Align` for spacing, padding, borders, colors, or multi-child layout — use `JBUI.Borders.empty(...)`, `UiStyle.Gap`, or an appropriate layout manager for those concerns.
### IntelliJ UI Surfaces
+2
View File
@@ -92,6 +92,8 @@ Production packaging still requires running `bun run build:production` so all pl
Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew runIdeBackend`) to launch just the backend half of a split-mode session. It prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary.
If `Run IDE (Backend)` exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting the backend.
Use `Run IDE (Split Mode)` to launch both halves at once (composes `Run IDE (Backend)` + `Run IDE (Frontend)`).
### Backend Gradle properties
@@ -13,6 +13,9 @@ import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.ConfigWarnings200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest
import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest
import ai.kilocode.rpc.dto.DeviceAuthDto
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
@@ -27,10 +30,18 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.util.concurrent.CopyOnWriteArrayList
@@ -550,6 +561,89 @@ class KiloBackendAppService private constructor(
_appState.value = KiloAppState.Disconnected
}
/**
* Refresh the user profile from the CLI backend.
* Returns the latest profile data, or null when not logged in.
* Updates the current [KiloAppState.Ready] profile in-place if the app is ready.
*/
suspend fun refreshProfile(): KiloProfile200Response? {
val result = fetchProfile()
val fresh = result.value
val current = _appState.value
if (current is KiloAppState.Ready) {
setAppReady(current.data.copy(profile = fresh))
}
profile = fresh
return fresh
}
/**
* Start the Kilo device auth login flow.
* Returns [DeviceAuthDto] containing the verification URL and code for display in the UI.
*/
suspend fun startLogin(directory: String?): DeviceAuthDto {
val client = connection.api ?: throw IllegalStateException("Not connected")
val body = ProviderOauthAuthorizeRequest(method = 0.0)
val response = client.providerOauthAuthorize(providerID = "kilo", directory = directory, providerOauthAuthorizeRequest = body)
val match = response.instructions.let { Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE).find(it) }
return DeviceAuthDto(
code = match?.groupValues?.get(1),
verificationUrl = response.url,
expiresIn = 900,
)
}
/**
* Complete the Kilo device auth login flow.
* Blocks until the user completes authentication on the browser side.
* Returns the user profile on success, or null if the login could not be completed.
*/
suspend fun completeLogin(directory: String?): KiloProfile200Response? {
val client = connection.api ?: throw IllegalStateException("Not connected")
client.providerOauthCallback(providerID = "kilo", directory = directory, providerOauthCallbackRequest = ProviderOauthCallbackRequest(method = 0.0))
return refreshProfile()
}
/**
* Log out from Kilo Gateway.
* Removes credentials and clears the profile from app state.
*/
suspend fun logout(): Boolean {
val client = connection.api ?: throw IllegalStateException("Not connected")
val result = client.authRemove(providerID = "kilo")
val current = _appState.value
if (current is KiloAppState.Ready) {
profile = null
setAppReady(current.data.copy(profile = null))
}
return result
}
/**
* Switch the active account context.
* Pass null for personal account, an organization ID for org context.
* Returns the updated profile after the switch.
*/
suspend fun setOrganization(organizationId: String?): KiloProfile200Response? {
val http = connection.apiClient ?: throw IllegalStateException("Not connected")
val body = JsonObject(
mapOf("organizationId" to (organizationId?.let { JsonPrimitive(it) } ?: JsonNull)),
).toString()
val request = Request.Builder()
.url("http://127.0.0.1:$port/kilo/organization")
.header("Accept", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
withContext(Dispatchers.IO) {
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("Organization switch failed: HTTP ${response.code} ${response.message}")
}
}
}
return refreshProfile()
}
override fun dispose() {
watcher?.cancel()
watcher = null
@@ -9,10 +9,6 @@ import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.ModelVariantUpdateDto
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import java.nio.file.Files
@@ -29,7 +25,6 @@ class KiloBackendModelStateManager(
private val DEFAULT_DIR = Path.of(System.getProperty("user.home"), ".local", "state", "kilo")
}
private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex()
private var client: OkHttpClient? = null
@@ -122,8 +117,7 @@ class KiloBackendModelStateManager(
return null
}
val raw = response.body?.string() ?: return null
val state = json.parseToJsonElement(raw).jsonObject["state"]?.jsonPrimitive?.contentOrNull
val dir = state?.let(Path::of) ?: DEFAULT_DIR
val dir = KiloCliDataParser.parsePathState(raw)?.let(Path::of) ?: DEFAULT_DIR
Files.createDirectories(dir)
dir.resolve("model.json").also { file = it }
}
@@ -1,5 +1,10 @@
package ai.kilocode.backend.cli
import ai.kilocode.backend.workspace.CommandInfo
import ai.kilocode.backend.workspace.ModelInfo
import ai.kilocode.backend.workspace.ModelLimitInfo
import ai.kilocode.backend.workspace.ProviderData
import ai.kilocode.backend.workspace.ProviderInfo
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.CloudSessionListDto
@@ -13,6 +18,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionFileDiffDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartTimeDto
@@ -286,6 +292,45 @@ object KiloCliDataParser {
)
}
/**
* Parse a provider catalog response (`GET /provider`) into [ProviderData].
* Throws if [raw] is not a valid JSON object (lets the workspace loading
* catch the exception and surface it as a LoadError).
*/
fun parseProviders(raw: String): ProviderData {
val obj = json.parseToJsonElement(raw).jsonObject
return ProviderData(
providers = obj["all"]?.jsonArray?.map { parseProvider(it.jsonObject) } ?: emptyList(),
connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
defaults = obj["default"]?.jsonObject?.mapValues { (_, v) -> v.jsonPrimitive.content } ?: emptyMap(),
)
}
/**
* Parse a command list response (`GET /command`) into a list of [CommandInfo].
* The `template` field is intentionally ignored — CLI commands can return lazy
* promise objects (`{}`) for that field, which must not crash JetBrains startup.
*/
fun parseCommands(raw: String): List<CommandInfo> =
json.parseToJsonElement(raw).jsonArray.map { item ->
val obj = item.jsonObject
CommandInfo(
name = obj.str("name") ?: "",
description = obj.str("description"),
source = obj.str("source"),
hints = obj["hints"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
)
}
/**
* Extract the `state` directory path from a `/path` response.
* Returns `null` when the field is missing, not a JSON string, or the JSON is malformed.
*/
fun parsePathState(raw: String): String? {
val prim = runCatching { tryParseObject(raw)?.get("state")?.jsonPrimitive }.getOrNull() ?: return null
return if (prim.isString) prim.content else null
}
fun parseModelState(raw: String): ModelStateDto {
val obj = tryParseObject(raw) ?: return ModelStateDto()
return ModelStateDto(
@@ -327,6 +372,14 @@ object KiloCliDataParser {
}
val sb = StringBuilder()
sb.append("""{"parts":[$parts]""")
val msg = prompt.messageID
if (msg != null) {
sb.append(""","messageID":${escape(msg)}""")
}
val reply = prompt.noReply
if (reply != null) {
sb.append(""","noReply":$reply""")
}
val pid = prompt.providerID
val mid = prompt.modelID
if (pid != null && mid != null) {
@@ -441,10 +494,16 @@ object KiloCliDataParser {
internal fun parseError(obj: JsonObject): MessageErrorDto {
val type = obj.str("type") ?: obj.str("name") ?: "unknown"
val data = obj["data"]?.jsonObject
val msg = obj.str("message")
?: obj["data"]?.jsonObject?.str("message")
?: data?.str("message")
?: obj.str("error")
return MessageErrorDto(type, msg)
return MessageErrorDto(
type,
msg,
statusCode = data?.long("statusCode")?.safeInt(),
responseBody = data?.str("responseBody"),
)
}
internal fun parsePermissionRequest(obj: JsonObject): PermissionRequestDto? {
@@ -453,11 +512,27 @@ object KiloCliDataParser {
val permission = obj.str("permission") ?: return null
val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
val meta = obj["metadata"]?.jsonObject?.let { m ->
m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") }
} ?: emptyMap()
val ref = toolRef(obj)
return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref)
val metaObj = obj["metadata"].obj()
val meta = metaObj?.entries?.mapNotNull { (key, value) ->
val text = value.scalar() ?: return@mapNotNull null
key to text
}?.toMap() ?: emptyMap()
val path = metaObj.path()
val diffs = metaObj.permissionDiffs(path)
return PermissionRequestDto(
id = id,
sessionID = sid,
permission = permission,
patterns = patterns,
metadata = meta,
always = always,
tool = toolRef(obj),
message = obj.str("message") ?: metaObj?.str("message"),
command = metaObj?.str("command") ?: obj.str("command"),
rules = metaObj.rules(),
filePath = path,
fileDiffs = diffs,
)
}
internal fun parseQuestionRequest(obj: JsonObject): QuestionRequestDto? {
@@ -520,6 +595,49 @@ object KiloCliDataParser {
"modelID" to JsonPrimitive(item.modelID),
))
// ================================================================
// Internal — provider/catalog parsing
// ================================================================
private val EFFORT_ORDER = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max")
.withIndex().associate { it.value to it.index }
private fun parseProvider(obj: JsonObject) = ProviderInfo(
id = obj.str("id") ?: "",
name = obj.str("name") ?: "",
source = obj.str("source"),
models = obj["models"]?.jsonObject?.mapValues { (id, v) -> parseModel(id, v.jsonObject) } ?: emptyMap(),
)
private fun parseModel(id: String, obj: JsonObject): ModelInfo {
val cap = obj["capabilities"]?.jsonObject
val limit = obj["limit"]?.jsonObject
return ModelInfo(
id = obj.str("id") ?: id,
name = obj.str("name") ?: id,
attachment = cap.bool("attachment"),
reasoning = cap.bool("reasoning"),
temperature = cap.bool("temperature"),
toolCall = cap.bool("toolcall"),
free = obj.bool("isFree"),
status = obj.str("status"),
recommendedIndex = obj.num("recommendedIndex"),
variants = parseVariants(obj),
limit = limit?.let {
ModelLimitInfo(
context = it.long("context") ?: 0,
input = it.long("input"),
output = it.long("output") ?: 0,
)
},
)
}
private fun parseVariants(obj: JsonObject): List<String> {
val keys = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList()
return keys.sortedWith(compareBy<String> { EFFORT_ORDER[it] ?: Int.MAX_VALUE }.thenBy { it })
}
private fun parseSessionObject(obj: JsonObject): SessionDto {
val time = obj["time"]?.jsonObject
val summary = obj["summary"]?.jsonObject
@@ -601,6 +719,11 @@ object KiloCliDataParser {
/**
* Build the JSON body for `POST /permission/{requestID}/reply`.
*/
internal fun parseRulesJson(text: String): List<String> {
val arr = runCatching { json.parseToJsonElement(text).jsonArray }.getOrNull() ?: return listOf(text)
return arr.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
}
fun buildPermissionReplyJson(reply: PermissionReplyDto): String {
val sb = StringBuilder()
sb.append("""{"reply":${escape(reply.reply)}""")
@@ -669,6 +792,70 @@ object KiloCliDataParser {
}
}
// Permission metadata helpers
private fun JsonElement?.obj(): JsonObject? = runCatching { this?.jsonObject }.getOrNull()
private fun JsonElement?.arr(): JsonArray? = runCatching { this?.jsonArray }.getOrNull()
private fun JsonObject?.path(): String? {
if (this == null) return null
return str("filepath") ?: str("filePath") ?: str("file") ?: str("path")
}
private fun JsonObject?.rules(): List<String> {
if (this == null) return emptyList()
val raw = this["rules"] ?: return emptyList()
val arr = raw.arr()
if (arr != null) {
return arr.mapNotNull { it.jsonPrimitive.contentOrNull }
}
val text = runCatching { raw.jsonPrimitive.contentOrNull }.getOrNull() ?: return emptyList()
if (text.startsWith("[")) {
return runCatching {
KiloCliDataParser.parseRulesJson(text)
}.getOrElse { listOf(text) }
}
return listOf(text)
}
private fun JsonObject?.permissionDiffs(path: String?): List<PermissionFileDiffDto> {
if (this == null) return emptyList()
val filediff = this["filediff"].obj()
if (filediff != null) {
val file = filediff.str("file") ?: filediff.str("relativePath") ?: path ?: return emptyList()
return listOf(
PermissionFileDiffDto(
file = file,
patch = filediff.str("patch"),
before = filediff.str("before"),
after = filediff.str("after"),
additions = filediff.long("additions")?.safeInt() ?: 0,
deletions = filediff.long("deletions")?.safeInt() ?: 0,
)
)
}
val files = this["files"].arr()
if (files != null) {
return files.mapNotNull { elem ->
val item = elem.obj() ?: return@mapNotNull null
val file = item.str("relativePath") ?: item.str("filePath") ?: item.str("file") ?: return@mapNotNull null
PermissionFileDiffDto(
file = file,
patch = item.str("patch"),
before = item.str("before"),
after = item.str("after"),
additions = item.long("additions")?.safeInt() ?: 0,
deletions = item.long("deletions")?.safeInt() ?: 0,
)
}
}
val diff = str("diff")
if (diff != null) {
return listOf(PermissionFileDiffDto(file = path ?: "patch", patch = diff))
}
return emptyList()
}
// JsonObject convenience extensions
private fun JsonObject.str(key: String): String? =
this[key]?.jsonPrimitive?.contentOrNull
@@ -679,6 +866,9 @@ private fun JsonObject.num(key: String): Double? =
private fun JsonObject.long(key: String): Long? =
this[key]?.jsonPrimitive?.longOrNull
private fun JsonObject?.bool(key: String): Boolean =
this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
private fun JsonObject?.map(key: String): Map<String, String> {
@@ -11,10 +11,12 @@ import ai.kilocode.backend.app.ProfileResult
import ai.kilocode.jetbrains.api.model.AgentConfig
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.ConfigAgent
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.rpc.dto.AgentConfigDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.DeviceAuthDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
@@ -24,6 +26,9 @@ import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto
import ai.kilocode.rpc.dto.ModelSelectionUpdateDto
import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.ModelVariantUpdateDto
import ai.kilocode.rpc.dto.ProfileBalanceDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileOrganizationDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import com.intellij.openapi.components.service
import kotlinx.coroutines.flow.Flow
@@ -63,6 +68,17 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
override suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto = app.models.variant(update)
override suspend fun refreshProfile(): ProfileDto? = app.refreshProfile()?.let(::profileDto)
override suspend fun startLogin(directory: String?): DeviceAuthDto = app.startLogin(directory)
override suspend fun completeLogin(directory: String?): ProfileDto? = app.completeLogin(directory)?.let(::profileDto)
override suspend fun logout(): Boolean = app.logout()
override suspend fun setOrganization(organizationId: String?): ProfileDto? =
app.setOrganization(organizationId)?.let(::profileDto)
private fun dto(state: KiloAppState): KiloAppStateDto =
appStateDto(state)
}
@@ -85,6 +101,7 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto =
),
warnings = state.data.warnings.map(::warning),
config = config(state.data.config),
profile = state.data.profile?.let(::profileDto),
)
is KiloAppState.Error -> KiloAppStateDto(
status = KiloAppStatusDto.ERROR,
@@ -93,6 +110,16 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto =
)
}
internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
email = p.profile.email,
name = p.profile.name,
organizations = p.profile.organizations.orEmpty().map { org ->
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
},
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
currentOrgId = p.currentOrgId,
)
private fun progress(p: LoadProgress) = LoadProgressDto(
config = p.config,
notifications = p.notifications,
@@ -3,20 +3,12 @@ package ai.kilocode.backend.workspace
import ai.kilocode.backend.app.KiloBackendSessionManager
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.app.SseEvent
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.log.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.jetbrains.api.model.Agent
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -57,10 +49,6 @@ class KiloBackendWorkspace(
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
private val json = Json { ignoreUnknownKeys = true }
private val EFFORT_ORDER = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max")
.withIndex()
.associate { it.value to it.index }
}
private val _state = MutableStateFlow<KiloWorkspaceState>(KiloWorkspaceState.Pending)
@@ -219,7 +207,7 @@ class KiloBackendWorkspace(
private fun fetchProviders(): FetchResult<ProviderData> =
try {
FetchResult.ok(parseProviders(fetch("/provider?directory=${encode(directory)}")))
FetchResult.ok(KiloCliDataParser.parseProviders(fetch("/provider?directory=${encode(directory)}")))
} catch (e: Exception) {
log.warn("Providers fetch failed: ${e.message}", e)
FetchResult.fail("providers", e)
@@ -242,14 +230,7 @@ class KiloBackendWorkspace(
private fun fetchCommands(): FetchResult<List<CommandInfo>> =
try {
FetchResult.ok(api.commandList(directory = directory).map { c ->
CommandInfo(
name = c.name,
description = c.description,
source = c.source?.value,
hints = c.hints,
)
})
FetchResult.ok(KiloCliDataParser.parseCommands(fetch("/command?directory=${encode(directory)}")))
} catch (e: Exception) {
log.warn("Commands fetch failed: ${e.message}", e)
FetchResult.fail("commands", e)
@@ -282,51 +263,6 @@ class KiloBackendWorkspace(
deprecated = a.deprecated,
)
private fun parseProviders(raw: String): ProviderData {
val obj = json.parseToJsonElement(raw).jsonObject
return ProviderData(
providers = obj["all"]?.jsonArray?.map { provider(it.jsonObject) } ?: emptyList(),
connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
defaults = obj["default"]?.jsonObject?.mapValues { (_, value) -> value.jsonPrimitive.content } ?: emptyMap(),
)
}
private fun provider(obj: JsonObject) = ProviderInfo(
id = obj.str("id") ?: "",
name = obj.str("name") ?: "",
source = obj.str("source"),
models = obj["models"]?.jsonObject?.mapValues { (id, value) -> model(id, value.jsonObject) } ?: emptyMap(),
)
private fun model(id: String, obj: JsonObject): ModelInfo {
val cap = obj["capabilities"]?.jsonObject
val limit = obj["limit"]?.jsonObject
return ModelInfo(
id = obj.str("id") ?: id,
name = obj.str("name") ?: id,
attachment = cap.bool("attachment"),
reasoning = cap.bool("reasoning"),
temperature = cap.bool("temperature"),
toolCall = cap.bool("toolcall"),
free = obj.bool("isFree"),
status = obj.str("status"),
recommendedIndex = obj.num("recommendedIndex"),
variants = variants(obj),
limit = limit?.let {
ModelLimitInfo(
context = it.long("context") ?: 0,
input = it.long("input"),
output = it.long("output") ?: 0,
)
},
)
}
private fun variants(obj: JsonObject): List<String> {
val raw = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList()
return raw.sortedWith(compareBy<String> { EFFORT_ORDER[it] ?: Int.MAX_VALUE }.thenBy { it })
}
private fun fetch(path: String): String {
val request = Request.Builder().url("http://localhost:$port$path").get().build()
http.newCall(request).execute().use { response ->
@@ -371,7 +307,3 @@ class KiloBackendWorkspace(
}
private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8)
private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull
private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false
private fun JsonObject.num(key: String) = this[key]?.jsonPrimitive?.doubleOrNull
private fun JsonObject.long(key: String) = this[key]?.jsonPrimitive?.longOrNull
@@ -164,6 +164,22 @@ class KiloBackendAppServiceTest {
assertEquals("alice@test.com", svc.profile!!.profile.email)
}
@Test
fun `set organization sends explicit null body for personal account`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
svc.setOrganization("org_1")
assertEquals("""{"organizationId":"org_1"}""", mock.lastOrganizationSetBody)
svc.setOrganization(null)
assertEquals("""{"organizationId":null}""", mock.lastOrganizationSetBody)
}
@Test
fun `profile 401 does not prevent Ready`() = runBlocking {
mock.profileStatus = 401
@@ -440,6 +456,41 @@ class KiloBackendAppServiceTest {
assertTrue((svc.appState.value as KiloAppState.Ready).data.warnings.isEmpty())
}
// ------ Auth mapping tests ------
@Test
fun `start login maps device auth response`() = runBlocking<Unit> {
// Default authorizeResponse: url=https://auth.kilo.ai/device, code=TEST-1234
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val auth = svc.startLogin(null)
assertEquals("https://auth.kilo.ai/device", auth.verificationUrl)
assertEquals("TEST-1234", auth.code)
assertEquals(900, auth.expiresIn)
assertNotNull(mock.lastAuthorizeBody)
}
@Test
fun `complete login calls callback and refreshes profile`() = runBlocking<Unit> {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val profile = svc.completeLogin(null)
assertNotNull(profile)
assertEquals("alice@test.com", profile.profile.email)
assertNotNull(mock.lastCallbackBody)
}
// ------ Concurrency & lifecycle tests ------
@Test
@@ -519,4 +570,150 @@ class KiloBackendAppServiceTest {
assertIs<KiloAppState.Ready>(svc.appState.value)
}
// ------ Profile DTO mapping tests ------
@Test
fun `ready dto maps profile fields`() = runBlocking {
mock.profile = """{
"profile":{
"email":"alice@test.com",
"name":"Alice",
"organizations":[{"id":"org_1","name":"Acme","role":"ADMIN"}]
},
"balance":{"balance":42.5},
"currentOrgId":"org_1"
}""".trimIndent()
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val dto = appStateDto(svc.appState.value)
assertEquals("alice@test.com", dto.profile?.email)
assertEquals("Alice", dto.profile?.name)
assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role)
assertEquals(42.5, dto.profile?.balance?.balance)
assertEquals("org_1", dto.profile?.currentOrgId)
}
@Test
fun `refresh profile updates ready dto profile`() = runBlocking {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
// Update mock to return different profile
mock.profile = """{"profile":{"email":"alice@test.com","name":"Updated Alice"},"balance":{"balance":99.0},"currentOrgId":null}"""
val fresh = svc.refreshProfile()
assertNotNull(fresh)
assertEquals("Updated Alice", fresh.profile.name)
assertEquals("Updated Alice", appStateDto(svc.appState.value).profile?.name)
assertEquals(99.0, appStateDto(svc.appState.value).profile?.balance?.balance)
}
@Test
fun `logout clears ready profile on success`() = runBlocking {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertNotNull(svc.profile)
mock.authRemoveStatus = 200
val ok = svc.logout()
assertTrue(ok)
assertNull(svc.profile)
assertNull(appStateDto(svc.appState.value).profile)
}
@Test
fun `set organization failure leaves profile unchanged`() = runBlocking {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val before = svc.profile
assertNotNull(before)
mock.organizationSetStatus = 500
var thrown = false
try {
svc.setOrganization("org_1")
} catch (_: Exception) {
thrown = true
}
assertTrue(thrown, "setOrganization with 500 should throw")
// Profile should remain unchanged because organization switch failed before refreshProfile
assertEquals(before.profile.email, svc.profile?.profile?.email)
}
@Test
fun `start login failure propagates`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
mock.authorizeStatus = 500
var thrown = false
try {
svc.startLogin(null)
} catch (_: Exception) {
thrown = true
}
assertTrue(thrown, "startLogin with 500 status should throw")
}
@Test
fun `start login without code returns null code but url present`() = runBlocking {
// Instructions without 'code:' — the regex match should return null
mock.authorizeResponse = """{"url":"https://auth.kilo.ai/device","method":"code","instructions":"Open the URL in your browser to sign in"}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val auth = svc.startLogin(null)
assertNull(auth.code, "code should be null when instructions have no code: prefix")
assertEquals("https://auth.kilo.ai/device", auth.verificationUrl)
}
@Test
fun `complete login callback failure propagates`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
mock.callbackStatus = 500
var thrown = false
try {
svc.completeLogin(null)
} catch (_: Exception) {
thrown = true
}
assertTrue(thrown, "completeLogin with 500 callback status should throw")
}
}
@@ -40,6 +40,16 @@ class MockCliServer : AutoCloseable {
@Volatile var warningsStatus = 200
@Volatile var notificationsStatus = 200
// Auth / OAuth responses
@Volatile var authorizeResponse = """{"url":"https://auth.kilo.ai/device","method":"code","instructions":"Open URL and enter code: TEST-1234"}"""
@Volatile var authorizeStatus = 200
@Volatile var callbackStatus = 200
@Volatile var authRemoveStatus = 200
@Volatile var organizationSetStatus = 200
@Volatile var lastAuthorizeBody: String? = null
@Volatile var lastCallbackBody: String? = null
@Volatile var lastOrganizationSetBody: String? = null
// Project-scoped REST responses
@Volatile var providers = """{"all":[],"default":{},"connected":[],"failed":[]}"""
@Volatile var agents = "[]"
@@ -219,13 +229,28 @@ class MockCliServer : AutoCloseable {
path == "/global/config" -> respond(output, configStatus, config)
path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings)
path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications)
path.startsWith("/kilo/profile") -> {
path.startsWith("/kilo/profile") && method == "GET" -> {
if (profileStatus == 401) {
respond(output, 401, """{"message":"Unauthorized"}""")
} else {
respond(output, profileStatus, profile)
}
}
path.matches(Regex("/provider/[^/]+/oauth/authorize.*")) && method == "POST" -> {
lastAuthorizeBody = body
respond(output, authorizeStatus, authorizeResponse)
}
path.matches(Regex("/provider/[^/]+/oauth/callback.*")) && method == "POST" -> {
lastCallbackBody = body
respond(output, callbackStatus, "true")
}
bare.matches(Regex("/auth/[^/]+")) && method == "DELETE" -> {
respond(output, authRemoveStatus, "true")
}
bare == "/kilo/organization" && method == "POST" -> {
lastOrganizationSetBody = body
respond(output, organizationSetStatus, "true")
}
path == "/global/event" -> handleSse(output)
path == "/path" -> respond(output, 200, this.path)
bare == "/provider" -> respond(output, providersStatus, providers)
@@ -274,9 +274,11 @@ class KiloBackendWorkspaceTest {
}
// ------ Data mapping ------
// Detailed provider/command/path parsing correctness is covered in KiloCliDataParserTest.
// These integration tests verify end-to-end data flow: server → workspace state.
@Test
fun `providers response maps models correctly`() = runBlocking {
fun `providers response reaches state with expected provider and model`() = runBlocking {
mock.providers = PROVIDERS_JSON
val app = setup()
val ws = ready(app)
@@ -286,20 +288,10 @@ class KiloBackendWorkspaceTest {
}
val state = ws.state.value as KiloWorkspaceState.Ready
val provider = state.providers.providers[0]
assertEquals("anthropic", provider.id)
assertEquals("Anthropic", provider.name)
val model = provider.models["claude-4"]
assertNotNull(model)
assertEquals("Claude 4", model.name)
assertTrue(model.attachment)
assertTrue(model.reasoning)
assertTrue(model.toolCall)
assertEquals(2.0, model.recommendedIndex)
assertEquals(listOf("low", "medium", "high"), model.variants)
assertEquals(200000L, model.limit?.context)
assertEquals(100000L, model.limit?.input)
assertEquals(16000L, model.limit?.output)
assertEquals(1, state.providers.providers.size)
assertEquals("anthropic", state.providers.providers[0].id)
assertNotNull(state.providers.providers[0].models["claude-4"])
assertEquals(listOf("anthropic"), state.providers.connected)
}
@Test
+1 -1
View File
@@ -46,7 +46,7 @@ val ver = if (release) checked(
val notes = providers.gradleProperty("kilo.changeNotes").orElse("Release candidate build.")
val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default")
val splitPort = providers.gradleProperty("kilo.splitModeServerPort").map(::port).orElse(providers.provider(::fallback))
val splitPort = providers.gradleProperty("kilo.splitModeServerPort").orNull?.let(::port) ?: fallback()
val isolated = providers.gradleProperty("kilo.dev.storage.isolated").map { it.toBoolean() }.orElse(false)
val worktreeRoot = providers.gradleProperty("kilo.dev.worktree.root").orElse(
providers.provider { rootProject.layout.projectDirectory.asFile.parentFile.parentFile.canonicalPath }
@@ -23,6 +23,7 @@ dependencies {
implementation(libs.commonmark.autolink)
implementation(libs.commonmark.tables)
implementation(libs.commonmark.strikethrough)
implementation(libs.zxing.core)
testImplementation(kotlin("test"))
testImplementation("junit:junit:4.13.2")
@@ -1,11 +1,10 @@
package ai.kilocode.client
import ai.kilocode.client.actions.HistoryAction
import ai.kilocode.client.actions.NewSessionAction
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionSidePanelManager
import ai.kilocode.log.KiloLog
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
@@ -65,8 +64,10 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware {
toolWindow.contentManager.setSelectedContent(content)
manager.newSession()
ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings ->
toolWindow.setTitleActions(listOf(NewSessionAction(), HistoryAction(), settings))
val toolbar = ActionManager.getInstance().getAction("Kilo.ToolWindowToolbar")
if (toolbar is ActionGroup) {
val actions = toolbar.getChildren(null).toList()
toolWindow.setTitleActions(actions)
}
} catch (e: Exception) {
LOG.error("Failed to set up Kilo tool window content", e)
@@ -0,0 +1,38 @@
package ai.kilocode.client.actions
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.remoting.ActionRemoteBehaviorSpecification
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.DumbAwareAction
import java.util.function.Predicate
/**
* Toolbar action that opens the Kilo User Profile settings page.
*
* Uses a predicate-based lookup so settings open correctly in JetBrains
* Remote Development where configurables may be wrapped.
*/
class ShowProfileAction : DumbAwareAction(
KiloBundle.message("action.Kilo.ShowProfile.text"),
KiloBundle.message("action.Kilo.ShowProfile.description"),
AllIcons.General.User,
), ActionRemoteBehaviorSpecification.Frontend {
override fun actionPerformed(e: AnActionEvent) {
ShowSettingsUtil.getInstance().showSettingsDialog(
e.project,
Predicate { cfg: Configurable ->
cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID
},
{ cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) },
)
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
@@ -3,6 +3,7 @@
package ai.kilocode.client.app
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.DeviceAuthDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
@@ -11,6 +12,8 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelSelectionUpdateDto
import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.ModelVariantUpdateDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import ai.kilocode.log.KiloLog
import com.intellij.openapi.components.Service
import fleet.rpc.client.durable
@@ -216,6 +219,59 @@ class KiloAppService internal constructor(
_favorites.value = state.favorite
}
/** Refresh the user profile and return the latest data. Null = not logged in. */
suspend fun refreshProfile(): ProfileDto? = try {
call { refreshProfile() }.also { setProfile(it) }
} catch (e: Exception) {
LOG.warn("profile refresh failed", e)
null
}
/** Refresh profile in fire-and-forget fashion from non-suspend context. */
fun refreshProfileAsync() {
cs.launch { refreshProfile() }
}
/**
* Start the Kilo device auth login flow.
* Returns [DeviceAuthDto] with the URL/code to display.
* Throws on failure.
*/
suspend fun startLogin(directory: String? = null): DeviceAuthDto = call { startLogin(directory) }
/**
* Complete the login flow. Blocks until authentication finishes.
* Returns the user profile, or null if unavailable.
*/
suspend fun completeLogin(directory: String? = null): ProfileDto? = try {
call { completeLogin(directory) }.also { setProfile(it) }
} catch (e: Exception) {
LOG.warn("login completion failed", e)
null
}
/** Log out and clear the user profile. */
suspend fun logout(): Boolean = try {
call { logout() }.also { ok ->
if (ok) setProfile(null)
}
} catch (e: Exception) {
LOG.warn("logout failed", e)
false
}
/**
* Switch active account context.
* Pass null for personal account, organization ID for org context.
* Returns the updated profile, or null if not logged in.
*/
suspend fun setOrganization(organizationId: String?): ProfileDto? = try {
call { setOrganization(organizationId) }.also { setProfile(it) }
} catch (e: Exception) {
LOG.warn("organization switch failed", e)
null
}
/**
* Collect app state changes and invoke [fn] for each update.
*/
@@ -227,4 +283,12 @@ class KiloAppService internal constructor(
}
}
}
private fun setProfile(profile: ProfileDto?) {
val current = _state.value
val progress = current.progress?.copy(
profile = if (profile == null) ProfileStatusDto.NOT_LOGGED_IN else ProfileStatusDto.LOADED,
)
_state.value = current.copy(profile = profile, progress = progress)
}
}
@@ -17,6 +17,7 @@ import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
@@ -25,9 +26,13 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.log.ChatLogSummary
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.ide.ui.LafManagerListener
@@ -36,8 +41,13 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.editor.colors.EditorColorsListener
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import java.util.function.Predicate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -58,7 +68,7 @@ class SessionUi(
workspace: Workspace,
sessions: KiloSessionService,
app: KiloAppService,
cs: CoroutineScope,
private val cs: CoroutineScope,
ref: SessionRef? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
private val manager: SessionManager? = null,
@@ -92,10 +102,12 @@ class SessionUi(
beforeUpdate = { if (opening) false else scroll.atBottom() },
afterUpdate = { if (!opening) scroll.followBottom(it) },
loaded = ::onSessionLoaded,
openProfileAction = ::openProfileSettings,
)
private lateinit var root: SessionRootPanel
private lateinit var account: SessionAccountOverlay
private lateinit var sessionContent: JPanel
@@ -111,6 +123,7 @@ class SessionUi(
private lateinit var question: QuestionView
private lateinit var permission: PermissionView
private lateinit var login: LoginRequiredView
private lateinit var connection: ConnectionPanel
private lateinit var prompt: PromptPanel
@@ -168,6 +181,22 @@ class SessionUi(
}
root.setBlocker(migrationOverlay)
account = SessionAccountOverlay(
select = { org -> controller.selectOrganization(org) },
profile = { controller.openProfile() },
)
root.addOverlay(account) { pane, child ->
val size = child.preferredSize
val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING)
val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING)
java.awt.Rectangle(
pane.width - size.width - right,
top,
size.width,
size.height,
)
}
sessionContent = JPanel(BorderLayout())
blankBody = JPanel(BorderLayout()).apply {
@@ -177,6 +206,7 @@ class SessionUi(
load = LoadingPanel()
progressBody = load
question = QuestionView(
project = project,
reply = { id, dto -> controller.replyQuestion(id, dto) },
reject = { id -> controller.rejectQuestion(id) },
scroll = { scroll.followBottom(true) },
@@ -184,7 +214,8 @@ class SessionUi(
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
messageBody = SessionMessageListPanel(controller.model, this, question, permission)
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -271,6 +302,8 @@ class SessionUi(
}
is SessionControllerEvent.ConnectionChanged -> Unit
is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event)
}
}
@@ -399,6 +432,16 @@ class SessionUi(
refresh()
}
private fun openProfileSettings() {
ShowSettingsUtil.getInstance().showSettingsDialog(
project,
Predicate { cfg: Configurable ->
cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID
},
{ cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) },
)
}
override fun dispose() {}
}
@@ -0,0 +1,32 @@
package ai.kilocode.client.session.controller
import ai.kilocode.rpc.dto.MessageErrorDto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
private const val PAID_MODEL_AUTH_REQUIRED = "PAID_MODEL_AUTH_REQUIRED"
private val json = Json { ignoreUnknownKeys = true }
/**
* Returns true when [error] signals that the user must sign in to use a paid model.
*
* Conditions (all must hold):
* - error type is "APIError"
* - statusCode is 401
* - response body contains `error.code` or `code` equal to "PAID_MODEL_AUTH_REQUIRED"
*
* Malformed or missing response body returns false rather than throwing.
*/
internal fun isPaidModelAuthRequired(error: MessageErrorDto?): Boolean {
if (error == null) return false
if (error.type != "APIError") return false
if (error.statusCode != 401) return false
val body = error.responseBody ?: return false
return runCatching {
val obj = json.parseToJsonElement(body).jsonObject
val nested = obj["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content
val top = obj["code"]?.jsonPrimitive?.content
nested == PAID_MODEL_AUTH_REQUIRED || top == PAID_MODEL_AUTH_REQUIRED
}.getOrNull() == true
}
@@ -11,6 +11,7 @@ import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.model.Question
@@ -21,10 +22,13 @@ import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
@@ -74,8 +78,11 @@ class SessionController(
private val beforeUpdate: () -> Boolean = { false },
private val afterUpdate: (Boolean) -> Unit = {},
private val loaded: (Boolean) -> Unit = {},
private val openProfileAction: () -> Unit = {},
) : Disposable {
private data class OrganizationTarget(val org: String?)
companion object {
private val LOG = KiloLog.create(SessionController::class.java)
internal const val RECENT_LIMIT = 5
@@ -105,12 +112,20 @@ class SessionController(
private var partType: String? = null
private var tool: String? = null
private var eventJob: Job? = null
private val childJobs: MutableMap<String, Job> = mutableMapOf()
private val childIds: MutableSet<String> = mutableSetOf()
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
private var recentsState: RecentsState = RecentsState.Idle
private var viewState: SessionControllerEvent.ViewChanged? = null
private var connectionState: SessionControllerEvent.ConnectionChanged? = null
private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null
private val connectionDelay = DelayedState(displayMs)
private var acctState: SessionControllerEvent.AccountOverlayChanged =
SessionControllerEvent.AccountOverlayChanged.Hide
private var acctAllowed = false
private var lastProfile: ProfileDto? = null
private var target: OrganizationTarget? = null
private var loginRetry: PromptDto? = null
val ready: Boolean get() = model.isReady()
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
@@ -304,6 +319,7 @@ class SessionController(
fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" }
updatePermission(requestId, PermissionRequestState.RESPONDING)
cs.launch {
try {
if (rules != null) sessions.savePermissionRules(requestId, directory, rules)
@@ -311,10 +327,29 @@ class SessionController(
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
updatePermission(
requestId,
PermissionRequestState.ERROR,
e.message ?: KiloBundle.message("session.permission.error"),
)
}
}
}
}
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
assertEdt()
val current = model.state
if (current !is SessionState.AwaitingPermission) return
if (current.permission.id != id) return
val perm = current.permission.copy(
state = state,
message = message ?: current.permission.message,
)
updateModel { model.setState(SessionState.AwaitingPermission(perm)) }
}
fun replyQuestion(requestId: String, answers: QuestionReplyDto) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" }
@@ -369,8 +404,12 @@ class SessionController(
fire(SessionControllerEvent.AppChanged) {
model.app = state
model.version = app.version
if (model.state is SessionState.LoginRequired && state.profile != null) {
resumeAfterLogin()
}
syncModelSelection()
syncConnectionState()
refreshAccountOverlay()
}
}
}
@@ -444,6 +483,7 @@ class SessionController(
val session = target.session ?: runCatching { sessions.get(id, directory) }.getOrNull()
val items = sessions.messages(id, directory)
LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" }
val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet()
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
@@ -456,6 +496,7 @@ class SessionController(
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
for (child in discovered) trackChild(child)
showSession()
loaded(!model.isEmpty())
}
@@ -488,6 +529,7 @@ class SessionController(
val session = sessions.importCloudSession(id, directory)
val items = sessions.messages(session.id, directory)
LOG.debug { "${ChatLogSummary.sid(session.id)} ${ChatLogSummary.history(items)}" }
val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet()
runEdt {
if (disposed) return@runEdt
ref = SessionRef.Local(session)
@@ -500,6 +542,7 @@ class SessionController(
recoverPending(session.id)
runEdt {
if (disposed) return@runEdt
for (child in discovered) trackChild(child)
subscribeEvents()
showSession()
loaded(!model.isEmpty())
@@ -537,6 +580,9 @@ class SessionController(
val id = sid ?: return
LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" }
eventJob?.cancel()
childJobs.values.forEach { it.cancel() }
childJobs.clear()
childIds.clear()
eventJob = cs.launch {
try {
sessions.events(id, directory).collect { event ->
@@ -553,6 +599,46 @@ class SessionController(
}
}
private fun subscribeChild(child: String) {
if (childJobs.containsKey(child)) return
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=true" }
val job = cs.launch {
try {
sessions.events(child, directory).collect { event ->
if (!isChildPermissionEvent(event, child)) return@collect
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-event child=$child ${ChatLogSummary.eventBody(event)}" }
updates.enqueue(event)
}
} finally {
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=false" }
}
}
childJobs[child] = job
}
private fun trackChild(child: String) {
if (!childIds.add(child)) return
subscribeChild(child)
cs.launch { recoverChildPermissions(child) }
}
private suspend fun recoverChildPermissions(child: String) {
try {
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
if (permissions.isEmpty()) return
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
val last = toPermission(permissions.last())
runEdt {
if (disposed) return@runEdt
// Do not overwrite an existing root or other child AwaitingPermission state
if (model.state is SessionState.AwaitingPermission) return@runEdt
updateModel { model.setState(SessionState.AwaitingPermission(last)) }
}
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
/** Rehydrate pending permissions/questions and current session status after history load. */
private suspend fun recoverPending(id: String) {
try {
@@ -625,6 +711,7 @@ class SessionController(
if (model.state is SessionState.Busy) {
model.setState(SessionState.Busy(status()))
}
childID(event.part)?.let { child -> trackChild(child) }
}
is ChatEventDto.PartDelta -> {
@@ -648,7 +735,7 @@ class SessionController(
tool = null
// "completed" always transitions to idle.
// Other reasons: don't clobber a more specific terminal state (Error,
// AwaitingPermission, AwaitingQuestion) that arrived just before close.
// AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close.
val current = model.state
val clobberOk = event.reason == "completed"
|| current is SessionState.Busy
@@ -660,8 +747,14 @@ class SessionController(
is ChatEventDto.Error -> {
partType = null
tool = null
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
model.setState(SessionState.Error(msg, event.error?.type))
if (isPaidModelAuthRequired(event.error)) {
loginRetry = retryPrompt()
showSession()
model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description")))
} else {
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
model.setState(SessionState.Error(msg, event.error?.type))
}
}
is ChatEventDto.MessageRemoved -> {
@@ -669,7 +762,8 @@ class SessionController(
}
is ChatEventDto.PermissionAsked -> {
model.setState(SessionState.AwaitingPermission(toPermission(event.request)))
val perm = toPermission(event.request)
model.setState(SessionState.AwaitingPermission(perm))
}
is ChatEventDto.PermissionReplied -> {
@@ -699,7 +793,11 @@ class SessionController(
is ChatEventDto.SessionStatusChanged -> {
val state = when (event.status.type) {
"idle" -> SessionState.Idle
"idle" -> {
val current = model.state
if (current is SessionState.LoginRequired) return
SessionState.Idle
}
"busy" -> {
val current = model.state
if (current is SessionState.Idle || current is SessionState.Error)
@@ -729,6 +827,7 @@ class SessionController(
if (current !is SessionState.Error
&& current !is SessionState.AwaitingPermission
&& current !is SessionState.AwaitingQuestion
&& current !is SessionState.LoginRequired
) {
model.setState(SessionState.Idle)
}
@@ -740,6 +839,48 @@ class SessionController(
}
}
private fun retryPrompt(): PromptDto? {
val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null
return PromptDto(
parts = emptyList(),
messageID = msg.info.id,
providerID = msg.info.providerID,
modelID = msg.info.modelID,
agent = msg.info.agent,
variant = model.variant?.takeIf { it in model.variants },
noReply = false,
)
}
private fun resumeAfterLogin() {
assertEdt()
val retry = loginRetry
loginRetry = null
if (retry == null) {
model.setState(SessionState.Idle)
return
}
val id = sid
if (id == null) {
model.setState(SessionState.Idle)
return
}
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
cs.launch {
try {
sessions.prompt(id, directory, retry)
LOG.debug { "${ChatLogSummary.sid(id)} kind=login-resume dispatched=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(id)} kind=login-resume dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (disposed) return@edt
val msg = e.message ?: KiloBundle.message("session.error.prompt")
model.setState(SessionState.Error(msg))
}
}
}
}
private fun promptDto(text: String): PromptDto {
val full = model.model
val sel = full?.let(::parseModel)
@@ -845,6 +986,87 @@ class SessionController(
else -> KiloBundle.message("session.status.considering")
}
fun selectOrganization(org: String?) {
assertEdt()
val next = OrganizationTarget(org)
if (target == next) return
target = next
refreshAccountOverlay()
cs.launch {
try {
app.setOrganization(org)
} catch (e: Exception) {
LOG.warn("account switch failed org=$org message=${e.message}", e)
edt {
if (disposed) return@edt
target = null
refreshAccountOverlay()
}
}
}
}
fun openProfile() {
assertEdt()
openProfileAction()
}
fun dismissLoginRequired() {
assertEdt()
loginRetry = null
if (model.state is SessionState.LoginRequired) {
updateModel { model.setState(SessionState.Idle) }
}
}
private fun accountSnapshot(): SessionControllerEvent.AccountOverlaySnapshot {
val state = model.app
val prof = state.profile
val pending = prof == null && state.progress?.profile == ProfileStatusDto.PENDING
val current = when {
prof != null -> prof
pending -> lastProfile
else -> null
}
if (prof != null) {
lastProfile = prof
if (target?.org == prof.currentOrgId) target = null
}
if (!pending && prof == null) {
lastProfile = null
target = null
}
return SessionControllerEvent.AccountOverlaySnapshot(
status = state.status,
profile = current,
transient = pending,
switching = target != null,
targetOrgId = target?.org,
)
}
private fun showAccountOverlay() {
acctAllowed = true
setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot()))
}
private fun hideAccountOverlay() {
acctAllowed = false
setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Hide)
}
private fun refreshAccountOverlay() {
if (!acctAllowed) return
setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot()))
}
private fun setAccountOverlayState(event: SessionControllerEvent.AccountOverlayChanged) {
if (acctState == event) return
fire(event) {
acctState = event
}
}
fun refreshRecents(force: Boolean = false) {
assertEdt()
if (!canUseRecents()) return
@@ -896,6 +1118,11 @@ class SessionController(
setRecentSessionsState(RecentsState.Idle)
}
}
when (event) {
is SessionControllerEvent.ViewChanged.ShowRecents -> showAccountOverlay()
is SessionControllerEvent.ViewChanged.ShowProgress -> hideAccountOverlay()
is SessionControllerEvent.ViewChanged.ShowSession -> hideAccountOverlay()
}
}
private fun openLocal() {
@@ -1004,6 +1231,7 @@ class SessionController(
val block: () -> Unit = {
if (!disposed) {
viewState?.let(listener::onEvent)
listener.onEvent(acctState)
connectionState?.let(listener::onEvent)
}
}
@@ -1039,6 +1267,9 @@ class SessionController(
disposed = true
connectionDelay.dispose()
eventJob?.cancel()
childJobs.values.forEach { it.cancel() }
childJobs.clear()
childIds.clear()
cs.cancel()
}
@@ -1083,12 +1314,29 @@ class SessionController(
out.add("[error]")
out.add("[${state.message}]")
}
is SessionState.LoginRequired -> {
out.add("[login-required]")
out.add("[${state.message}]")
}
}
return out.joinToString(" ")
}
}
/** Extracts the child session ID from a task tool part's metadata, or null if not a task part. */
private fun childID(part: PartDto): String? {
if (part.type != "tool" || part.tool != "task") return null
return part.metadata["sessionId"]
}
/** Returns true when [event] is a permission event for [child] (used by child subscriptions). */
private fun isChildPermissionEvent(event: ChatEventDto, child: String): Boolean = when (event) {
is ChatEventDto.PermissionAsked -> event.sessionID == child
is ChatEventDto.PermissionReplied -> event.sessionID == child
else -> false
}
/** Returns true when [event]'s sessionID matches [id] (or event has no sessionID, like Error). */
private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (event) {
is ChatEventDto.MessageUpdated -> event.sessionID == id
@@ -1213,17 +1461,40 @@ private fun ConfigWarningDto.toDetailLine(): String {
private fun toPermission(dto: PermissionRequestDto): Permission {
val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) }
val file = dto.metadata["file"] ?: dto.metadata["path"]
val state = dto.metadata["state"]?.let { raw ->
PermissionRequestState.values().firstOrNull { item -> item.name.equals(raw, ignoreCase = true) }
} ?: PermissionRequestState.PENDING
val diffs = dto.fileDiffs.map {
PermissionFileDiff(
file = it.file,
patch = it.patch,
before = it.before,
after = it.after,
additions = it.additions,
deletions = it.deletions,
)
}
val file = dto.filePath
?: dto.metadata["filepath"]
?: dto.metadata["filePath"]
?: dto.metadata["file"]
?: dto.metadata["path"]
return Permission(
id = dto.id,
sessionId = dto.sessionID,
name = dto.permission,
patterns = dto.patterns,
always = dto.always,
meta = PermissionMeta(filePath = file, raw = dto.metadata),
meta = PermissionMeta(
command = dto.command ?: dto.metadata["command"],
rules = dto.rules,
diff = dto.metadata["diff"],
filePath = file,
fileDiff = diffs.firstOrNull(),
fileDiffs = diffs,
raw = dto.metadata,
),
message = dto.message ?: dto.metadata["message"],
tool = ref,
state = state,
)
@@ -2,6 +2,8 @@ package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.SessionDto
/**
@@ -34,6 +36,24 @@ sealed class SessionControllerEvent {
}
}
data class AccountOverlaySnapshot(
val status: KiloAppStatusDto,
val profile: ProfileDto?,
val transient: Boolean = false,
val switching: Boolean = false,
val targetOrgId: String? = null,
)
sealed class AccountOverlayChanged : SessionControllerEvent() {
data class Show(val account: AccountOverlaySnapshot) : AccountOverlayChanged() {
override fun toString() = "AccountOverlayChanged show loggedIn=${account.profile != null}"
}
data object Hide : AccountOverlayChanged() {
override fun toString() = "AccountOverlayChanged hide"
}
}
sealed class ConnectionChanged : SessionControllerEvent() {
data object Hide : ConnectionChanged() {
override fun toString() = "ConnectionChanged hide"
@@ -17,10 +17,12 @@ data class Permission(
)
data class PermissionMeta(
val command: String? = null,
val rules: List<String> = emptyList(),
val diff: String? = null,
val filePath: String? = null,
val fileDiff: PermissionFileDiff? = null,
val fileDiffs: List<PermissionFileDiff> = emptyList(),
val raw: Map<String, String> = emptyMap(),
)
@@ -18,8 +18,10 @@ sealed class SessionState {
data class Error(val message: String, val kind: String? = null) : SessionState()
data class LoginRequired(val message: String) : SessionState()
fun isBusy(): Boolean = when (this) {
is Idle, is Loading, is Error -> false
is Idle, is Loading, is Error, is LoginRequired -> false
else -> true
}
}
@@ -28,6 +28,7 @@ internal class SessionScroll(
companion object {
private const val THRESHOLD = 32
private const val OPEN_PASSES = 12
private const val FOLLOW_PASSES = 6
}
val component = JBScrollPane(body).apply {
@@ -95,10 +96,14 @@ internal class SessionScroll(
return
}
tail = true
stable = -1
auto = true
show(messages)
auto = false
followPass(++seq, 2)
val id = ++seq
ApplicationManager.getApplication().invokeLater {
followPass(id, FOLLOW_PASSES)
}
}
fun openBottom(done: () -> Unit) {
@@ -134,12 +139,16 @@ internal class SessionScroll(
auto = true
show(messages)
auto = false
followPass(++seq, 2)
val id = ++seq
ApplicationManager.getApplication().invokeLater {
followPass(id, FOLLOW_PASSES)
}
}
private fun followPass(id: Int, remaining: Int) {
if (id != seq || !tail) return
auto = true
val prev = bottom()
try {
layoutScroll()
scrollToBottom()
@@ -147,9 +156,15 @@ internal class SessionScroll(
} finally {
auto = false
}
if (remaining <= 0) return
if (remaining <= 0) {
stable = -1
return
}
val next = bottom()
val left = if (next == prev && next == stable) remaining - 1 else FOLLOW_PASSES
stable = next
ApplicationManager.getApplication().invokeLater {
followPass(id, remaining - 1)
followPass(id, left)
}
}
@@ -10,8 +10,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.ui.CenterShrinkPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
@@ -44,7 +47,7 @@ import javax.swing.ListSelectionModel
* Empty-session panel.
*
* The content is a BorderLayout panel, wrapped in a
* [CenterShrinkPanel] (exposed as [view]) so callers need not know about centering.
* [Align] (exposed as [view]) so callers need not know about centering.
*/
class EmptySessionPanel(
parent: Disposable,
@@ -52,7 +55,7 @@ class EmptySessionPanel(
recents: List<SessionDto>,
private val history: () -> Unit = {},
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: CenterShrinkPanel = CenterShrinkPanel(this)
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
@@ -133,7 +136,7 @@ class EmptySessionPanel(
val header = BorderLayoutPanel(0, gap).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
add(CenterShrinkPanel(description), BorderLayout.CENTER)
add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}
val recent = BorderLayoutPanel().apply {
@@ -303,8 +306,8 @@ class EmptySessionPanel(
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
welcomeLabel.font = style.uiFont
recentTitle.font = style.smallUiFont
welcomeLabel.font = style.regularFont
recentTitle.font = style.smallFont
revalidate()
repaint()
}
@@ -18,7 +18,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget {
}
override fun applyStyle(style: SessionEditorStyle) {
label.font = style.uiFont
label.font = style.regularFont
revalidate()
repaint()
}
@@ -62,7 +62,7 @@ class ProgressPanel(
}
override fun applyStyle(style: SessionEditorStyle) {
label.font = style.uiFont
label.font = style.regularFont
revalidate()
repaint()
}
@@ -7,8 +7,9 @@ import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TurnView
import com.intellij.openapi.Disposable
@@ -44,6 +45,7 @@ class SessionMessageListPanel(
parent: Disposable,
private val question: QuestionView? = null,
private val permission: PermissionView? = null,
private val login: LoginRequiredView? = null,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -248,8 +250,8 @@ class SessionMessageListPanel(
}
/**
* Show or hide active question/permission views based on [state].
* Both views are always kept as children of this panel (added in [anchorFooter]),
* Show or hide active question/permission/login views based on [state].
* All views are always kept as children of this panel (added in [anchorFooter]),
* but visibility is controlled here.
*/
private fun syncActive(state: SessionState = model.state) {
@@ -257,17 +259,26 @@ class SessionMessageListPanel(
is SessionState.AwaitingQuestion -> {
setHiddenQuestionTool(state.question.tool)
permission?.hideView()
login?.hideView()
question?.show(state.question)
}
is SessionState.AwaitingPermission -> {
setHiddenQuestionTool(null)
question?.hideView()
login?.hideView()
permission?.show(state.permission)
}
is SessionState.LoginRequired -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
login?.show(state.message)
}
else -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
login?.hideView()
}
}
}
@@ -280,19 +291,21 @@ class SessionMessageListPanel(
}
/**
* Re-insert [question], [permission], and [progress] as the last children
* Re-insert [question], [permission], [login], and [progress] as the last children
* so active views always render after all turn views, and progress is last.
*
* Both active views are added even when invisible — [SessionLayout] skips
* All active views are added even when invisible — [SessionLayout] skips
* invisible children, so no extra space is consumed, and the component tree
* remains stable for tests.
*/
private fun anchorFooter() {
if (question != null) remove(question)
if (permission != null) remove(permission)
if (login != null) remove(login)
remove(progress)
if (question != null) add(question)
if (permission != null) add(permission)
if (login != null) add(login)
add(progress)
}
@@ -317,6 +330,7 @@ class SessionMessageListPanel(
for (view in turnViews.values) view.applyStyle(style)
question?.applyStyle(style)
permission?.applyStyle(style)
login?.applyStyle(style)
progress.applyStyle(style)
refresh()
}
@@ -0,0 +1,5 @@
package ai.kilocode.client.session.ui.account
internal data class AccountChoice(val org: String?, val title: String) {
override fun toString() = title
}
@@ -0,0 +1,69 @@
package ai.kilocode.client.session.ui.account
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
internal class AccountPickerRenderer(
private val active: () -> String?,
) : JPanel(BorderLayout()), ListCellRenderer<AccountChoice> {
companion object {
val checked: javax.swing.Icon = AllIcons.Actions.Checked
val empty: javax.swing.Icon = EmptyIcon.create(checked)
}
private val icon = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
private val title = JBLabel().apply {
horizontalAlignment = SwingConstants.LEFT
verticalAlignment = SwingConstants.CENTER
}
private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0))
private val wrap = PickerRow()
init {
UiStyle.Components.transparent(this, icon, title, row)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
)
row.add(icon, BorderLayout.WEST)
row.add(title, BorderLayout.CENTER)
wrap.setContent(row)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
list: JList<out AccountChoice>,
value: AccountChoice,
index: Int,
selected: Boolean,
focused: Boolean,
): Component {
val focus = selected || list.hasFocus() || focused
val fg = UIUtil.getListForeground(selected, focus)
background = list.background
wrap.update(list, selected, focus)
icon.icon = icon(value)
title.text = value.title
title.foreground = fg
return this
}
internal fun icon(value: AccountChoice): javax.swing.Icon =
if (value.org == active()) checked else empty
}
@@ -0,0 +1,300 @@
package ai.kilocode.client.session.ui.account
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.PickerButton
import ai.kilocode.client.ui.RoundedContentPanel
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ListUtil
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import ai.kilocode.client.settings.profile.formatBalance
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Cursor
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.ScrollPaneConstants
/**
* Compact account overlay shown in the top-right of the empty session screen.
*
* Only visible when logged in. Hidden when not logged in or no profile is available.
* Visibility is controlled entirely by [onEvent] — never set [isVisible] externally.
*/
internal class SessionAccountOverlay(
private val select: (String?) -> Unit,
private val profile: () -> Unit,
) : BorderLayoutPanel() {
private val picker = PickerButton().apply {
isEnabled = false
text = " "
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (!isEnabled || choices.isEmpty()) return
showPopup()
}
})
}
private var balanceText: String? = null
private val balance = JBLabel().apply {
isVisible = false
}
private val profileBtn = HoverIcon().apply {
icon = AllIcons.General.User
toolTipText = KiloBundle.message("action.Kilo.ShowProfile.description")
accessibleContext.accessibleName = KiloBundle.message("action.Kilo.ShowProfile.text")
addActionListener { profile() }
}
private val row = JPanel().apply {
layout = BoxLayout(this, BoxLayout.X_AXIS)
isOpaque = false
add(picker)
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
add(balance)
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
add(profileBtn)
}
private val panel = RoundedContentPanel(UiStyle.Gap.lg(), UiStyle.Gap.lg()).apply {
addToCenter(row)
}
private var choices: List<AccountChoice> = emptyList()
private var currentOrgId: String? = null
init {
isOpaque = false
isVisible = false
addToCenter(panel)
}
@RequiresEdt
fun onEvent(event: SessionControllerEvent.AccountOverlayChanged) {
var layout = false
var paint = false
when (event) {
is SessionControllerEvent.AccountOverlayChanged.Hide -> {
if (isVisible) {
isVisible = false
layout = true
paint = true
}
}
is SessionControllerEvent.AccountOverlayChanged.Show -> {
val snap = event.account
val prof = snap.profile
if (prof == null) {
if (!snap.transient && isVisible) {
isVisible = false
layout = true
paint = true
}
} else {
layout = updateLoggedIn(prof, snap.switching, snap.targetOrgId) || layout
if (!isVisible) {
isVisible = true
layout = true
}
}
}
}
if (layout) revalidate()
if (layout || paint) repaint()
}
@RequiresEdt
private fun updateLoggedIn(prof: ai.kilocode.rpc.dto.ProfileDto, switching: Boolean, target: String?): Boolean {
var layout = false
val orgs = prof.organizations
val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) +
orgs.map { org -> AccountChoice(org.id, org.name) }
if (next != choices) {
choices = next
layout = true
}
if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId
val activeId = if (switching) target else prof.currentOrgId
val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull()
val title = "${active?.title ?: " "}"
if (picker.text != title) {
picker.text = title
layout = true
}
val enabled = !switching
if (picker.isEnabled != enabled) {
picker.isEnabled = enabled
picker.repaint()
}
val tip = if (switching) {
KiloBundle.message("profile.switchingAccount")
} else {
KiloBundle.message("session.account.switcher")
}
if (picker.toolTipText != tip) picker.toolTipText = tip
layout = syncBalance(prof) || layout
return layout
}
@RequiresEdt
private fun syncBalance(prof: ai.kilocode.rpc.dto.ProfileDto): Boolean {
var layout = false
val next = prof.balance?.let { formatBalance(it.balance) }
if (next == null) {
if (balance.isVisible) {
balance.isVisible = false
layout = true
}
if (balance.icon != null) {
balance.icon = null
}
if (balance.toolTipText != null) balance.toolTipText = null
balanceText = null
} else {
if (!balance.isVisible) {
balance.isVisible = true
layout = true
}
if (balanceText != next || balance.icon == null) {
balance.icon = FilledBadgeIcon(
next,
UiStyle.Colors.badgeBg(),
UiStyle.Colors.badgeFg(),
)
layout = true
}
val tip = KiloBundle.message("session.account.balance", next)
if (balance.toolTipText != tip) balance.toolTipText = tip
balanceText = next
}
return layout
}
@RequiresEdt
private fun showPopup() {
val bg = UiStyle.Colors.cardBg()
val model = CollectionListModel(choices)
val list = JBList(model).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
background = bg
border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
list.cellRenderer = AccountPickerRenderer { currentOrgId }
val idx = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0
if (idx >= 0) {
list.selectedIndex = idx
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
}
lateinit var popup: com.intellij.openapi.ui.popup.JBPopup
fun activate(choice: AccountChoice) {
if (choice.org != currentOrgId) select(choice.org)
popup.closeOk(null)
}
list.addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
val row = list.locationToIndex(e.point)
val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return
if (!bounds.contains(e.point)) return
activate(model.getElementAt(row))
}
})
ListUtil.installAutoSelectOnMouseMove(list)
ScrollingUtil.installActions(list)
list.registerKeyboardAction(
{ list.selectedValue?.let(::activate) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
list.registerKeyboardAction(
{ popup.cancel() },
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
JComponent.WHEN_FOCUSED,
)
val scroll = ScrollPaneFactory.createScrollPane(list).apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
background = bg
viewport.background = bg
viewport.isOpaque = true
}
val content = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.sm()).apply {
addToCenter(scroll)
}
popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(content, list)
.setRequestFocus(true)
.setFocusable(true)
.setCancelOnClickOutside(true)
.setCancelKeyEnabled(true)
.setCancelOnWindowDeactivation(true)
.setResizable(false)
.setMovable(false)
.createPopup()
popup.showUnderneathOf(picker)
}
/**
* Activate an account choice without showing the popup.
* Only calls [select] when the choice differs from [currentOrgId].
* Used by tests and by the popup's confirm action.
*/
@RequiresEdt
internal fun activate(choice: AccountChoice) {
if (choice.org != currentOrgId) select(choice.org)
}
internal fun loggedInVisible() = isVisible
internal fun accountTitle(): String? = picker.text?.removeSuffix("")?.ifBlank { null }
internal fun pickerEnabled() = picker.isEnabled
internal fun pickerVisible() = picker.isVisible
internal fun choiceCount() = choices.size
internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0
internal fun panelBackground() = panel.background
internal fun panelBorderColor() = UiStyle.Colors.cardBorder()
internal fun balanceVisible() = balance.isVisible
internal fun balanceIcon() = balance.icon
internal fun balanceText() = balanceText
internal fun profileIcon() = profileBtn.icon
internal fun clickProfile() = profileBtn.doClick()
}
@@ -0,0 +1,32 @@
package ai.kilocode.client.session.ui.editor
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.SendPromptContext
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.project.Project
import com.intellij.ui.EditorTextField
/**
* A session-scoped [EditorTextField] for plain-text input.
*
* When [ctx] is non-null the component injects it into the data context so
* shortcut-based send/stop actions work (prompt use-case). When [ctx] is null
* the component does not expose [PromptDataKeys.SEND], preventing accidental
* `SendPromptAction` dispatch from question custom-answer editors.
*
* Both instances are created on the EDT. The underlying [EditorTextField]
* lazily initializes its IntelliJ editor the first time the component becomes
* visible; that initialization calls `EditorThreading.compute` internally,
* satisfying the platform's read-context requirement without additional
* wrapping here.
*/
internal open class SessionEditorTextField(
project: Project,
private val ctx: SendPromptContext? = null,
) : EditorTextField(project, PlainTextFileType.INSTANCE) {
override fun uiDataSnapshot(sink: DataSink) {
super.uiDataSnapshot(sink)
ctx?.let { sink.set(PromptDataKeys.SEND, it) }
}
}
@@ -44,9 +44,9 @@ internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.md(), 0)) {
background = style.editorBackground
foreground = style.editorForeground
meter.background = style.editorBackground
used.font = style.smallUiFont
used.font = style.smallFont
used.foreground = style.editorForeground
limit.font = style.smallUiFont
limit.font = style.smallFont
limit.foreground = style.editorForeground
}
@@ -229,23 +229,23 @@ class SessionHeaderPanel(
todoRow.background = style.editorBackground
body.background = style.editorBackground
viewport.background = style.editorBackground
title.font = style.boldUiFont
title.font = style.boldFont
title.foreground = style.editorForeground
cost.font = style.uiFont
cost.font = style.regularFont
cost.foreground = style.editorForeground
context.font = style.uiFont
context.font = style.regularFont
context.foreground = style.editorForeground
todos.font = style.smallUiFont
todos.font = style.smallFont
todos.foreground = style.editorForeground
tokenTitle.font = style.smallUiFont
tokenTitle.font = style.smallFont
tokenTitle.foreground = style.editorForeground
input.font = style.smallUiFont
input.font = style.smallFont
input.foreground = style.editorForeground
output.font = style.smallUiFont
output.font = style.smallFont
output.foreground = style.editorForeground
cacheRead.font = style.smallUiFont
cacheRead.font = style.smallFont
cacheRead.foreground = style.editorForeground
cacheWrite.font = style.smallUiFont
cacheWrite.font = style.smallFont
cacheWrite.foreground = style.editorForeground
bar.applyStyle(style)
refresh()
@@ -391,7 +391,7 @@ class SessionHeaderPanel(
expand.accessibleContext.accessibleName = KiloBundle.message(key)
}
private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, true)
private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, false)
private fun sizeTimeline() {
val size = timeline.preferredSize
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.CollectionListModel
@@ -12,18 +13,12 @@ import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Point
import java.awt.Rectangle
import java.awt.RenderingHints
import java.awt.font.FontRenderContext
import javax.swing.Icon
import javax.swing.JList
import javax.swing.JPanel
@@ -70,7 +65,11 @@ internal class ModelPickerRenderer(
verticalAlignment = SwingConstants.CENTER
}
private val title = SimpleColoredComponent()
private val badge = BadgeIcon
private val badge = FilledBadgeIcon(
KiloBundle.message("model.picker.free"),
ModelText.freeBg(),
JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE),
)
private val provider = JBLabel()
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
add(title)
@@ -157,33 +156,4 @@ internal class ModelPickerRenderer(
internal fun badgeVisible(): Boolean = head.getComponent(1).isVisible
private class BadgeLabel(icon: Icon) : JBLabel(icon)
private object BadgeIcon : Icon {
private val text = KiloBundle.message("model.picker.free")
override fun getIconWidth(): Int {
val font = JBFont.small()
val w = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt()
return w + JBUI.scale(12)
}
override fun getIconHeight(): Int = JBUI.scale(16)
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.translate(x, y)
g2.color = ModelText.freeBg()
g2.fillRoundRect(0, 0, iconWidth, iconHeight, JBUI.scale(4), JBUI.scale(4))
g2.color = JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE)
g2.font = JBFont.small()
val fm = g2.fontMetrics
val y = (iconHeight + fm.ascent - fm.descent) / 2
g2.drawString(text, JBUI.scale(6), y)
} finally {
g2.dispose()
}
}
}
}
@@ -1,16 +1,9 @@
package ai.kilocode.client.session.ui.prompt
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.fileTypes.PlainTextFileType
import ai.kilocode.client.session.ui.editor.SessionEditorTextField
import com.intellij.openapi.project.Project
import com.intellij.ui.EditorTextField
internal class PromptEditorTextField(
project: Project,
private val ctx: SendPromptContext,
) : EditorTextField(project, PlainTextFileType.INSTANCE) {
override fun uiDataSnapshot(sink: DataSink) {
super.uiDataSnapshot(sink)
sink.set(PromptDataKeys.SEND, ctx)
}
}
ctx: SendPromptContext,
) : SessionEditorTextField(project, ctx)
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.RoundedContentPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.log.ChatLogSummary
@@ -357,55 +358,22 @@ class PromptPanel(
}
}
private inner class PromptShell : BorderLayoutPanel() {
private val arc = JBValue.UIInteger("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)
private inner class PromptShell : RoundedContentPanel(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
) {
private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH)
init {
isOpaque = false
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
override fun contentColor() = style.editorScheme.defaultBackground
override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) {
JBUI.CurrentTheme.Focus.focusColor()
} else {
SessionUiStyle.View.line()
}
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
}
override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1)
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON,
)
g2.color = style.editorScheme.defaultBackground
val size = arc.get()
g2.fillRoundRect(0, 0, width, height, size, size)
val active = UIUtil.isFocusAncestor(editor)
g2.color = if (active) {
JBUI.CurrentTheme.Focus.focusColor()
} else {
SessionUiStyle.View.line()
}
val bw = if (active) focus.get() else JBUI.scale(1)
for (idx in 0 until bw) {
val inset = idx
val w = width - inset * 2 - 1
val h = height - inset * 2 - 1
if (w > 0 && h > 0) {
g2.drawRoundRect(inset, inset, w, h, size, size)
}
}
} finally {
g2.dispose()
}
super.paintComponent(g)
}
override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
}
}
@@ -1,10 +1,10 @@
package ai.kilocode.client.session.ui.style
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorColorsScheme
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Font
import kotlin.math.roundToInt
@@ -14,6 +14,12 @@ import kotlin.math.roundToInt
*
* Session UI uses this instead of reading editor globals in every component so font and color changes can be applied
* consistently through [SessionEditorStyleTarget].
*
* Editor-specific fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [editorForeground], [editorBackground])
* are derived from the active editor color scheme and are used for code/editor-rendered content.
*
* UI font fields ([headerFont], [hintFont], [regularFont], [boldFont], [smallFont]) come from [UiStyle.Fonts]
* and follow standard platform typography they do not derive from the editor font size.
*/
data class SessionEditorStyle(
val editorScheme: EditorColorsScheme,
@@ -24,9 +30,11 @@ data class SessionEditorStyle(
val transcriptFont: Font,
val smallEditorFont: Font,
val boldEditorFont: Font,
val uiFont: Font,
val smallUiFont: Font,
val boldUiFont: Font,
val headerFont: Font,
val hintFont: Font,
val regularFont: Font,
val boldFont: Font,
val smallFont: Font,
) {
/** Apply this snapshot to embedded IntelliJ editor components used by session UI. */
fun applyToEditor(editor: EditorEx) {
@@ -46,9 +54,7 @@ data class SessionEditorStyle(
family: String = scheme.editorFontName,
size: Int = scheme.editorFontSize,
): SessionEditorStyle {
val small = scaledSize(size, JBFont.small())
val ui = JBUI.Fonts.label().deriveFont(size.toFloat())
val smallUi = JBFont.small().deriveFont(small.toFloat())
val small = scaledEditorSize(size, JBFont.small())
return SessionEditorStyle(
editorScheme = scheme,
editorFamily = family,
@@ -58,14 +64,16 @@ data class SessionEditorStyle(
transcriptFont = Font(family, Font.PLAIN, size),
smallEditorFont = Font(family, Font.PLAIN, small),
boldEditorFont = Font(family, Font.BOLD, size),
uiFont = ui,
smallUiFont = smallUi,
boldUiFont = ui.deriveFont(Font.BOLD),
headerFont = UiStyle.Fonts.header(),
hintFont = UiStyle.Fonts.hint(),
regularFont = UiStyle.Fonts.regular(),
boldFont = UiStyle.Fonts.bold(),
smallFont = UiStyle.Fonts.small(),
)
}
private fun scaledSize(size: Int, font: Font): Int {
val base = JBUI.Fonts.label().size.coerceAtLeast(1)
private fun scaledEditorSize(size: Int, font: Font): Int {
val base = com.intellij.util.ui.JBUI.Fonts.label().size.coerceAtLeast(1)
val ratio = font.size.toFloat() / base
return (size * ratio).roundToInt().coerceAtLeast(1)
}
@@ -72,6 +72,11 @@ object SessionUiStyle {
const val USER_BORDER_HORIZONTAL_PADDING = 12
}
/** Permission card command preview limits. */
object Permission {
const val COMMAND_LINES = 3
}
/** Tool card preview limits and state colors. */
object Tool {
const val BODY_LINES = 15
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
@@ -67,8 +68,8 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par
override fun update(content: Content) {} // compaction has no mutable state
override fun applyStyle(style: SessionEditorStyle) {
if (text.font == style.smallUiFont) return
text.font = style.smallUiFont
if (text.font == style.smallFont) return
text.font = style.smallFont
revalidate()
repaint()
}

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