diff --git a/.changeset/background-process-ports.md b/.changeset/background-process-ports.md new file mode 100644 index 00000000000..dfaaddcc676 --- /dev/null +++ b/.changeset/background-process-ports.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show detected ports for tracked background processes in the TUI sidebar and process detail dialog. diff --git a/.changeset/background-processes-cli.md b/.changeset/background-processes-cli.md new file mode 100644 index 00000000000..6aed067705b --- /dev/null +++ b/.changeset/background-processes-cli.md @@ -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. diff --git a/.changeset/breezy-models-float.md b/.changeset/breezy-models-float.md deleted file mode 100644 index 84b355eb520..00000000000 --- a/.changeset/breezy-models-float.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve the JetBrains model picker with sections, favorites, and smart filtering. diff --git a/.changeset/bright-windows-logo.md b/.changeset/bright-windows-logo.md new file mode 100644 index 00000000000..c0ed555e384 --- /dev/null +++ b/.changeset/bright-windows-logo.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Use the fallback logo in old Windows terminal emulators while keeping the Unicode logo available over SSH. diff --git a/.changeset/chilly-dancers-build.md b/.changeset/chilly-dancers-build.md deleted file mode 100644 index 3f8b568bc5d..00000000000 --- a/.changeset/chilly-dancers-build.md +++ /dev/null @@ -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. diff --git a/.changeset/custom-question-jetbrains.md b/.changeset/custom-question-jetbrains.md new file mode 100644 index 00000000000..97a63d7f16c --- /dev/null +++ b/.changeset/custom-question-jetbrains.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support typed custom responses to question prompts in the JetBrains plugin. diff --git a/.changeset/editor-sized-jetbrains-chat.md b/.changeset/editor-sized-jetbrains-chat.md deleted file mode 100644 index 221bd814f6e..00000000000 --- a/.changeset/editor-sized-jetbrains-chat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Scale JetBrains chat transcript fonts from the editor font settings. diff --git a/.changeset/favorite-picker-index.md b/.changeset/favorite-picker-index.md deleted file mode 100644 index 602a32bfe03..00000000000 --- a/.changeset/favorite-picker-index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Keep the JetBrains model picker at the same row position when models are favorited or unfavorited. diff --git a/.changeset/fix-command-template-serialization.md b/.changeset/fix-command-template-serialization.md new file mode 100644 index 00000000000..eb158322998 --- /dev/null +++ b/.changeset/fix-command-template-serialization.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains startup when command templates are returned as lazy objects. diff --git a/.changeset/fuzzy-berries-press.md b/.changeset/fuzzy-berries-press.md deleted file mode 100644 index 0c48bad5166..00000000000 --- a/.changeset/fuzzy-berries-press.md +++ /dev/null @@ -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. diff --git a/.changeset/green-planes-reply.md b/.changeset/green-planes-reply.md deleted file mode 100644 index f9b3517d9c4..00000000000 --- a/.changeset/green-planes-reply.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-chat-renderers.md b/.changeset/jetbrains-chat-renderers.md deleted file mode 100644 index 0a099733c1b..00000000000 --- a/.changeset/jetbrains-chat-renderers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Match VS Code-style reasoning and tool run rendering in JetBrains chat. diff --git a/.changeset/jetbrains-empty-panel-layout.md b/.changeset/jetbrains-empty-panel-layout.md deleted file mode 100644 index 3f13739eeb8..00000000000 --- a/.changeset/jetbrains-empty-panel-layout.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-existing-session-scroll.md b/.changeset/jetbrains-existing-session-scroll.md deleted file mode 100644 index 534092cc3f9..00000000000 --- a/.changeset/jetbrains-existing-session-scroll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Open existing JetBrains sessions scrolled to the latest message after history loads. diff --git a/.changeset/jetbrains-header-tooltips.md b/.changeset/jetbrains-header-tooltips.md deleted file mode 100644 index eabe4154aa9..00000000000 --- a/.changeset/jetbrains-header-tooltips.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-history-navigation.md b/.changeset/jetbrains-history-navigation.md deleted file mode 100644 index 92f361fc775..00000000000 --- a/.changeset/jetbrains-history-navigation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve JetBrains history navigation with Back support and a Show History action in recent sessions. diff --git a/.changeset/jetbrains-history-tabs.md b/.changeset/jetbrains-history-tabs.md deleted file mode 100644 index 859a598e3e8..00000000000 --- a/.changeset/jetbrains-history-tabs.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-logged-out-account-panel.md b/.changeset/jetbrains-logged-out-account-panel.md new file mode 100644 index 00000000000..67599a66745 --- /dev/null +++ b/.changeset/jetbrains-logged-out-account-panel.md @@ -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. diff --git a/.changeset/jetbrains-login-dismiss.md b/.changeset/jetbrains-login-dismiss.md new file mode 100644 index 00000000000..4df6bb9e3b3 --- /dev/null +++ b/.changeset/jetbrains-login-dismiss.md @@ -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. diff --git a/.changeset/jetbrains-login-qr.md b/.changeset/jetbrains-login-qr.md new file mode 100644 index 00000000000..2e3447eee2e --- /dev/null +++ b/.changeset/jetbrains-login-qr.md @@ -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. diff --git a/.changeset/jetbrains-mode-picker.md b/.changeset/jetbrains-mode-picker.md deleted file mode 100644 index 7d3b6b2011d..00000000000 --- a/.changeset/jetbrains-mode-picker.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve the JetBrains mode picker with descriptions, deprecated badges, and native IntelliJ selection behavior. diff --git a/.changeset/jetbrains-paid-model-login.md b/.changeset/jetbrains-paid-model-login.md new file mode 100644 index 00000000000..f2ed31b2e8a --- /dev/null +++ b/.changeset/jetbrains-paid-model-login.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a sign-in prompt in JetBrains sessions when a paid model requires login. diff --git a/.changeset/jetbrains-permission-compact-rows.md b/.changeset/jetbrains-permission-compact-rows.md new file mode 100644 index 00000000000..495d1ded6cd --- /dev/null +++ b/.changeset/jetbrains-permission-compact-rows.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains permission prompts with compact action rows and diff badges. diff --git a/.changeset/jetbrains-profile-polish.md b/.changeset/jetbrains-profile-polish.md new file mode 100644 index 00000000000..5af1f098434 --- /dev/null +++ b/.changeset/jetbrains-profile-polish.md @@ -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. diff --git a/.changeset/jetbrains-profile-settings.md b/.changeset/jetbrains-profile-settings.md new file mode 100644 index 00000000000..7c715449215 --- /dev/null +++ b/.changeset/jetbrains-profile-settings.md @@ -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. diff --git a/.changeset/jetbrains-question-carousel.md b/.changeset/jetbrains-question-carousel.md deleted file mode 100644 index 943811f3f0f..00000000000 --- a/.changeset/jetbrains-question-carousel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show JetBrains question prompts one question at a time with aligned native option rows. diff --git a/.changeset/jetbrains-scroll-bottom.md b/.changeset/jetbrains-scroll-bottom.md deleted file mode 100644 index b98469fac73..00000000000 --- a/.changeset/jetbrains-scroll-bottom.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Keep the JetBrains chat transcript pinned to bottom reliably while responses stream. diff --git a/.changeset/jetbrains-scroll-button.md b/.changeset/jetbrains-scroll-button.md deleted file mode 100644 index 31762b7e48c..00000000000 --- a/.changeset/jetbrains-scroll-button.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-scrollable-prompts.md b/.changeset/jetbrains-scrollable-prompts.md deleted file mode 100644 index 0a923961601..00000000000 --- a/.changeset/jetbrains-scrollable-prompts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Render active question and permission prompts inside the scrollable JetBrains chat transcript. diff --git a/.changeset/jetbrains-send-action.md b/.changeset/jetbrains-send-action.md deleted file mode 100644 index 6f6f30a3d41..00000000000 --- a/.changeset/jetbrains-send-action.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Support rebinding the JetBrains prompt Send action in the IntelliJ keymap. diff --git a/.changeset/jetbrains-session-account-overlay.md b/.changeset/jetbrains-session-account-overlay.md new file mode 100644 index 00000000000..600bc055108 --- /dev/null +++ b/.changeset/jetbrains-session-account-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show account login, switching, and balance controls on the empty JetBrains session screen. diff --git a/.changeset/jetbrains-session-header.md b/.changeset/jetbrains-session-header.md deleted file mode 100644 index 9c13aa6c9d5..00000000000 --- a/.changeset/jetbrains-session-header.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show collapsible session metadata, context usage, token usage, and compaction controls in the JetBrains session header. diff --git a/.changeset/jetbrains-session-history.md b/.changeset/jetbrains-session-history.md deleted file mode 100644 index 8aa827208cc..00000000000 --- a/.changeset/jetbrains-session-history.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Add a native session history panel to the JetBrains plugin. diff --git a/.changeset/jetbrains-session-layout-padding.md b/.changeset/jetbrains-session-layout-padding.md deleted file mode 100644 index 2fbf40c42d8..00000000000 --- a/.changeset/jetbrains-session-layout-padding.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Control JetBrains chat transcript spacing from the session layout. diff --git a/.changeset/jetbrains-session-loading-state.md b/.changeset/jetbrains-session-loading-state.md deleted file mode 100644 index bc930ebcbca..00000000000 --- a/.changeset/jetbrains-session-loading-state.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions. diff --git a/.changeset/jetbrains-session-scroll-layout.md b/.changeset/jetbrains-session-scroll-layout.md deleted file mode 100644 index ea065c67885..00000000000 --- a/.changeset/jetbrains-session-scroll-layout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Reduce redundant JetBrains chat scroll layout work while keeping bottom-follow behavior. diff --git a/.changeset/jetbrains-step-finish-timeline.md b/.changeset/jetbrains-step-finish-timeline.md deleted file mode 100644 index 6afba10a4bc..00000000000 --- a/.changeset/jetbrains-step-finish-timeline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show completed assistant steps in the JetBrains session timeline. diff --git a/.changeset/jetbrains-stop-action.md b/.changeset/jetbrains-stop-action.md deleted file mode 100644 index 34bdae579dc..00000000000 --- a/.changeset/jetbrains-stop-action.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Support stopping active JetBrains sessions from the prompt action button. diff --git a/.changeset/mingw-terminal-keyboard.md b/.changeset/mingw-terminal-keyboard.md new file mode 100644 index 00000000000..c6135cc3e10 --- /dev/null +++ b/.changeset/mingw-terminal-keyboard.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Avoid leaving mouse and advanced keyboard modes enabled after exiting the TUI in mintty and MINGW terminals. diff --git a/.changeset/model-picker-natural-size.md b/.changeset/model-picker-natural-size.md deleted file mode 100644 index 5fa0954e274..00000000000 --- a/.changeset/model-picker-natural-size.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Size the JetBrains model picker from its rendered model rows instead of fixed popup dimensions. diff --git a/.changeset/quiet-otters-reason.md b/.changeset/quiet-otters-reason.md deleted file mode 100644 index ad7b31eb35f..00000000000 --- a/.changeset/quiet-otters-reason.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Support reasoning effort selection and resetting JetBrains chat model overrides to the default model. diff --git a/.changeset/reasoning-picker-align.md b/.changeset/reasoning-picker-align.md deleted file mode 100644 index 4a21b9e9762..00000000000 --- a/.changeset/reasoning-picker-align.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Align checked and unchecked reasoning picker rows in the JetBrains plugin. diff --git a/.changeset/session-expandable-defaults.md b/.changeset/session-expandable-defaults.md new file mode 100644 index 00000000000..905b3adb933 --- /dev/null +++ b/.changeset/session-expandable-defaults.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Start expandable session sections collapsed by default. diff --git a/.changeset/session-question-view-style.md b/.changeset/session-question-view-style.md new file mode 100644 index 00000000000..ca25c743d45 --- /dev/null +++ b/.changeset/session-question-view-style.md @@ -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. diff --git a/.changeset/slow-terms-dance.md b/.changeset/slow-terms-dance.md deleted file mode 100644 index 777d5bec8e7..00000000000 --- a/.changeset/slow-terms-dance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Keep the JetBrains session connection status above the prompt without shifting the chat layout. diff --git a/.changeset/soft-garlic-care.md b/.changeset/soft-garlic-care.md deleted file mode 100644 index ee6f55a6d61..00000000000 --- a/.changeset/soft-garlic-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Refine JetBrains session loading UI and settings actions. diff --git a/.changeset/steady-crabs-scroll.md b/.changeset/steady-crabs-scroll.md deleted file mode 100644 index 9688855539f..00000000000 --- a/.changeset/steady-crabs-scroll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Preserve JetBrains chat scroll position when reviewing earlier session output. diff --git a/.changeset/tidy-otters-wait.md b/.changeset/tidy-otters-wait.md deleted file mode 100644 index 151fcfe79af..00000000000 --- a/.changeset/tidy-otters-wait.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Avoid flickering transient loading and connection error states in JetBrains sessions. diff --git a/.gitignore b/.gitignore index 9b1c9faf3b8..812dece5848 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ a.out target .scripts .direnv/ +packages/kilo-jetbrains/build-tasks/.gradle/ # Local dev files opencode-dev diff --git a/.opencode-version b/.opencode-version index 92f061fdfe4..2aa995bb4eb 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.34 +v1.14.41 diff --git a/bun.lock b/bun.lock index 63815becec4..1028ef07ec4 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index ac1138ed240..e8171c93a62 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index ba17e14417d..478b03234d8 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/core/package.json b/packages/core/package.json index a06949867ca..bbd292174e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 645c1a976f3..223322300fa 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -87,6 +87,8 @@ export const layer = Layer.effect( Effect.sync(() => Service.of(make())), ) +export const defaultLayer = layer + export const layerWith = (input: Partial) => Layer.effect( Service, diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 1200b19fa72..7d432b02ba1 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 9af565b9384..9b0d75f6267 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -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", diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png new file mode 100644 index 00000000000..b557ae59935 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18d394d726ebee312e54db5d45f55cca3a1728d2b41f9f73bb9b610096bf53e0 +size 41004 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 07c1546a508..dc0b0236abd 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -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", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index a53edba010c..bd5b2396af6 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -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", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 1c83c68870b..e6069c60a4d 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -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", diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 6f6576fb3a8..8afdd0ecac3 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -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 diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index f8f7150705f..146feaba28e 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index e3e374c33e1..fb0f6d8430e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt index c73ebd0c1fa..3cbd48dd7ff 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt @@ -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 } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 9e097feae5c..c4a920cc04a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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 = + 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 { + val keys = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList() + return keys.sortedWith(compareBy { 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 { + 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 { + 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 { + 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 { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 27358ac843a..c02afa53371 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -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, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index f5b41711f3e..5aa272cf72e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -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.Pending) @@ -219,7 +207,7 @@ class KiloBackendWorkspace( private fun fetchProviders(): FetchResult = 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> = 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 { - val raw = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList() - return raw.sortedWith(compareBy { 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 diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index b3cfc1b486f..6864fb20f0f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -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 { + // 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 { + 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(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") + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 6b1f3c980fe..c614d14bdb2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.backend.cli -import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -10,8 +11,10 @@ import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.QuestionReplyDto +import org.junit.jupiter.api.Nested import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -22,1122 +25,1492 @@ import kotlin.test.assertTrue * No mocks, no services, no coroutines — just JSON in → DTO out. * When a new parsing bug is found, copy the raw JSON that caused * the issue and add a test case here. + * + * Tests are grouped into three nested classes: + * - [SseEvents] — SSE/chat event parsing + * - [HttpResponses] — HTTP response body parsing + * - [RequestBuilders] — outgoing JSON body builders and local model state */ class KiloCliDataParserTest { // ================================================================ - // extractEventType + // Group 1 — SSE / chat event parsing // ================================================================ - @Test - fun `extractEventType - parses type from JSON data`() { - val result = KiloCliDataParser.extractEventType( - """{"type":"global.config.updated","payload":{}}""" - ) - assertEquals("global.config.updated", result) - } + @Nested + inner class SseEvents { - @Test - fun `extractEventType - returns unknown for missing type`() { - assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}""")) - } + // ---- extractEventType ---- - @Test - fun `extractEventType - returns unknown for empty string`() { - assertEquals("unknown", KiloCliDataParser.extractEventType("")) - } + @Test + fun `extractEventType - parses type from JSON data`() { + val result = KiloCliDataParser.extractEventType( + """{"type":"global.config.updated","payload":{}}""" + ) + assertEquals("global.config.updated", result) + } - // ================================================================ - // parseChatEvent — GlobalEvent wrapper - // ================================================================ + @Test + fun `extractEventType - returns unknown for missing type`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}""")) + } - @Test - fun `parseChatEvent - message updated with GlobalEvent wrapper`() { - val data = """{ - "directory": "/tmp/test", - "payload": { + @Test + fun `extractEventType - returns unknown for empty string`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("")) + } + + // ---- parseChatEvent — GlobalEvent wrapper ---- + + @Test + fun `parseChatEvent - message updated with GlobalEvent wrapper`() { + val data = """{ + "directory": "/tmp/test", + "payload": { + "type": "message.updated", + "properties": { + "sessionID": "ses_123", + "info": { + "id": "msg_1", + "sessionID": "ses_123", + "role": "assistant", + "time": { "created": 1000.0 } + } + } + } + }""" + + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_123", result.sessionID) + assertEquals("msg_1", result.info.id) + assertEquals("assistant", result.info.role) + } + + @Test + fun `parseChatEvent - flat event without payload wrapper`() { + val data = """{ "type": "message.updated", "properties": { - "sessionID": "ses_123", - "info": { - "id": "msg_1", - "sessionID": "ses_123", - "role": "assistant", - "time": { "created": 1000.0 } - } - } - } - }""" - - val result = KiloCliDataParser.parseChatEvent("message.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.MessageUpdated) - assertEquals("ses_123", result.sessionID) - assertEquals("msg_1", result.info.id) - assertEquals("assistant", result.info.role) - } - - @Test - fun `parseChatEvent - flat event without payload wrapper`() { - val data = """{ - "type": "message.updated", - "properties": { - "sessionID": "ses_456", - "info": { - "id": "msg_2", "sessionID": "ses_456", - "role": "user", - "time": { "created": 2000.0 } - } - } - }""" - - val result = KiloCliDataParser.parseChatEvent("message.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.MessageUpdated) - assertEquals("ses_456", result.sessionID) - assertEquals("user", result.info.role) - } - - // ================================================================ - // parseChatEvent — specific event types - // ================================================================ - - @Test - fun `parseChatEvent - message part delta`() { - val data = globalEvent(""" - "type": "message.part.delta", - "properties": { - "sessionID": "ses_1", - "messageID": "msg_1", - "partID": "part_1", - "field": "text", - "delta": "Hello world" - } - """) - - val result = KiloCliDataParser.parseChatEvent("message.part.delta", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartDelta) - assertEquals("ses_1", result.sessionID) - assertEquals("msg_1", result.messageID) - assertEquals("part_1", result.partID) - assertEquals("text", result.field) - assertEquals("Hello world", result.delta) - } - - @Test - fun `parseChatEvent - message part updated`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_1", - "sessionID": "ses_1", - "messageID": "msg_1", - "type": "text", - "text": "Hello" - } - } - """) - - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartUpdated) - assertEquals("ses_1", result.sessionID) - assertEquals("part_1", result.part.id) - assertEquals("text", result.part.type) - assertEquals("Hello", result.part.text) - } - - @Test - fun `parseChatEvent - read tool part preserves input metadata and time`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_read", - "sessionID": "ses_1", - "messageID": "msg_1", - "type": "tool", - "tool": "read", - "callID": "call_read", - "metadata": { "loaded": ["README.MD"] }, - "state": { - "status": "completed", - "input": { "filePath": "README.MD", "limit": 200 }, - "metadata": { "source": "workspace" }, - "title": "Read README.MD", - "time": { "start": 10, "end": 12 } + "info": { + "id": "msg_2", + "sessionID": "ses_456", + "role": "user", + "time": { "created": 2000.0 } } } - } - """) + }""" - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartUpdated) - assertEquals("read", result.part.tool) - assertEquals("completed", result.part.state) - assertEquals("Read README.MD", result.part.title) - assertEquals("README.MD", result.part.input["filePath"]) - assertEquals("200", result.part.input["limit"]) - assertEquals("workspace", result.part.metadata["source"]) - assertEquals("[\"README.MD\"]", result.part.metadata["loaded"]) - assertEquals(10.0, result.part.time?.start) - assertEquals(12.0, result.part.time?.end) - } + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_456", result.sessionID) + assertEquals("user", result.info.role) + } - @Test - fun `parseChatEvent - bash tool part preserves command output and error`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_bash", + // ---- parseChatEvent — specific event types ---- + + @Test + fun `parseChatEvent - message part delta`() { + val data = globalEvent(""" + "type": "message.part.delta", + "properties": { "sessionID": "ses_1", "messageID": "msg_1", - "type": "tool", - "tool": "bash", - "callID": "call_bash", - "state": { - "status": "error", - "input": { - "command": "git remote -v", - "description": "View git remote URLs" - }, - "metadata": { "command": "git remote -v" }, - "output": "origin git@example.com:repo.git", - "error": "exit code 1", - "time": { "start": 20, "end": 25 } + "partID": "part_1", + "field": "text", + "delta": "Hello world" + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.delta", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartDelta) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + assertEquals("text", result.field) + assertEquals("Hello world", result.delta) + } + + @Test + fun `parseChatEvent - message part updated`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "text", + "text": "Hello" } } - } - """) + """) - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartUpdated) - assertEquals("bash", result.part.tool) - assertEquals("error", result.part.state) - assertEquals("git remote -v", result.part.input["command"]) - assertEquals("View git remote URLs", result.part.input["description"]) - assertEquals("origin git@example.com:repo.git", result.part.output) - assertEquals("exit code 1", result.part.error) - assertEquals(20.0, result.part.time?.start) - assertEquals(25.0, result.part.time?.end) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("part_1", result.part.id) + assertEquals("text", result.part.type) + assertEquals("Hello", result.part.text) + } - @Test - fun `parseChatEvent - turn open`() { - val data = globalEvent(""" - "type": "session.turn.open", - "properties": { "sessionID": "ses_1" } - """) + @Test + fun `parseChatEvent - read tool part preserves input metadata and time`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_read", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "read", + "callID": "call_read", + "metadata": { "loaded": ["README.MD"] }, + "state": { + "status": "completed", + "input": { "filePath": "README.MD", "limit": 200 }, + "metadata": { "source": "workspace" }, + "title": "Read README.MD", + "time": { "start": 10, "end": 12 } + } + } + } + """) - val result = KiloCliDataParser.parseChatEvent("session.turn.open", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.TurnOpen) - assertEquals("ses_1", result.sessionID) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("read", result.part.tool) + assertEquals("completed", result.part.state) + assertEquals("Read README.MD", result.part.title) + assertEquals("README.MD", result.part.input["filePath"]) + assertEquals("200", result.part.input["limit"]) + assertEquals("workspace", result.part.metadata["source"]) + assertEquals("[\"README.MD\"]", result.part.metadata["loaded"]) + assertEquals(10.0, result.part.time?.start) + assertEquals(12.0, result.part.time?.end) + } - @Test - fun `parseChatEvent - turn close`() { - val data = globalEvent(""" - "type": "session.turn.close", - "properties": { "sessionID": "ses_1", "reason": "completed" } - """) + @Test + fun `parseChatEvent - bash tool part preserves command output and error`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_bash", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "bash", + "callID": "call_bash", + "state": { + "status": "error", + "input": { + "command": "git remote -v", + "description": "View git remote URLs" + }, + "metadata": { "command": "git remote -v" }, + "output": "origin git@example.com:repo.git", + "error": "exit code 1", + "time": { "start": 20, "end": 25 } + } + } + } + """) - val result = KiloCliDataParser.parseChatEvent("session.turn.close", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.TurnClose) - assertEquals("ses_1", result.sessionID) - assertEquals("completed", result.reason) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("bash", result.part.tool) + assertEquals("error", result.part.state) + assertEquals("git remote -v", result.part.input["command"]) + assertEquals("View git remote URLs", result.part.input["description"]) + assertEquals("origin git@example.com:repo.git", result.part.output) + assertEquals("exit code 1", result.part.error) + assertEquals(20.0, result.part.time?.start) + assertEquals(25.0, result.part.time?.end) + } - @Test - fun `parseChatEvent - session error`() { - val data = globalEvent(""" - "type": "session.error", - "properties": { - "sessionID": "ses_1", - "error": { "type": "provider_auth", "message": "Invalid key" } - } - """) + @Test + fun `parseChatEvent - part updated with callID`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "bash", + "callID": "call_abc", + "state": { "status": "running" } + } + } + """) - val result = KiloCliDataParser.parseChatEvent("session.error", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.Error) - assertEquals("ses_1", result.sessionID) - assertEquals("provider_auth", result.error?.type) - assertEquals("Invalid key", result.error?.message) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + assertEquals("call_abc", result.part.callID) + assertEquals("bash", result.part.tool) + } - @Test - fun `parseChatEvent - message removed`() { - val data = globalEvent(""" - "type": "message.removed", - "properties": { "sessionID": "ses_1", "messageID": "msg_1" } - """) + @Test + fun `parseChatEvent - turn open`() { + val data = globalEvent(""" + "type": "session.turn.open", + "properties": { "sessionID": "ses_1" } + """) - val result = KiloCliDataParser.parseChatEvent("message.removed", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.MessageRemoved) - assertEquals("ses_1", result.sessionID) - assertEquals("msg_1", result.messageID) - } + val result = KiloCliDataParser.parseChatEvent("session.turn.open", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnOpen) + assertEquals("ses_1", result.sessionID) + } - // ================================================================ - // parseChatEvent — error cases - // ================================================================ + @Test + fun `parseChatEvent - turn close`() { + val data = globalEvent(""" + "type": "session.turn.close", + "properties": { "sessionID": "ses_1", "reason": "completed" } + """) - @Test - fun `parseChatEvent - unknown type returns null`() { - val data = globalEvent(""" - "type": "some.unknown.event", - "properties": { "sessionID": "ses_1" } - """) - assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data)) - } + val result = KiloCliDataParser.parseChatEvent("session.turn.close", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnClose) + assertEquals("ses_1", result.sessionID) + assertEquals("completed", result.reason) + } - @Test - fun `parseChatEvent - malformed JSON returns null`() { - assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json")) - } + @Test + fun `parseChatEvent - session error`() { + val data = globalEvent(""" + "type": "session.error", + "properties": { + "sessionID": "ses_1", + "error": { "type": "provider_auth", "message": "Invalid key" } + } + """) - @Test - fun `parseChatEvent - missing properties returns null`() { - assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}""")) - } + val result = KiloCliDataParser.parseChatEvent("session.error", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.Error) + assertEquals("ses_1", result.sessionID) + assertEquals("provider_auth", result.error?.type) + assertEquals("Invalid key", result.error?.message) + } - @Test - fun `parseChatEvent - missing sessionID returns null`() { - val data = globalEvent(""" - "type": "message.updated", - "properties": { "info": { "id": "msg_1", "role": "user", "time": {} } } - """) - assertNull(KiloCliDataParser.parseChatEvent("message.updated", data)) - } + @Test + fun `parseChatEvent - session error preserves API error details`() { + val data = globalEvent(""" + "type": "session.error", + "properties": { + "sessionID": "ses_1", + "error": { + "name": "APIError", + "message": "Unauthorized", + "data": { + "statusCode": 401, + "responseBody": "{\"error\":{\"code\":\"PAID_MODEL_AUTH_REQUIRED\"}}" + } + } + } + """) - // ================================================================ - // parseSessionStatus - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("session.error", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.Error) + assertEquals("ses_1", result.sessionID) + assertEquals("APIError", result.error?.type) + assertEquals("Unauthorized", result.error?.message) + assertEquals(401, result.error?.statusCode) + assertEquals("""{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""", result.error?.responseBody) + } - @Test - fun `parseSessionStatus - valid status event`() { - val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""" - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("ses_abc", result.first) - assertEquals("busy", result.second.type) - assertEquals("Running...", result.second.message) - } + @Test + fun `parseChatEvent - message removed`() { + val data = globalEvent(""" + "type": "message.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1" } + """) - @Test - fun `parseSessionStatus - missing sessionID returns null`() { - val data = """{"status":{"type":"idle"}}""" - assertNull(KiloCliDataParser.parseSessionStatus(data)) - } + val result = KiloCliDataParser.parseChatEvent("message.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + } - @Test - fun `parseSessionStatus - missing status defaults to idle`() { - val data = """{"sessionID":"ses_xyz"}""" - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("idle", result.second.type) - assertNull(result.second.message) - } + @Test + fun `parseChatEvent - message part removed`() { + val data = globalEvent(""" + "type": "message.part.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" } + """) - // ================================================================ - // parseSession - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("message.part.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + } - @Test - fun `parseSession - full session response`() { - val raw = """{ - "id": "ses_abc", - "projectID": "proj_1", - "directory": "/tmp/project", - "parentID": null, - "title": "Test session", - "version": "1", - "time": { "created": 1000.0, "updated": 2000.0 }, - "summary": { "additions": 10, "deletions": 5, "files": 3 } - }""" + // ---- session lifecycle events ---- - val result = KiloCliDataParser.parseSession(raw) - assertEquals("ses_abc", result.id) - assertEquals("proj_1", result.projectID) - assertEquals("/tmp/project", result.directory) - assertNull(result.parentID) - assertEquals("Test session", result.title) - assertEquals(1000.0, result.time.created) - assertEquals(2000.0, result.time.updated) - assertNotNull(result.summary) - assertEquals(10, result.summary?.additions) - assertEquals(5, result.summary?.deletions) - assertEquals(3, result.summary?.files) - } + @Test + fun `parseChatEvent - session idle`() { + val data = globalEvent(""" + "type": "session.idle", + "properties": { "sessionID": "ses_1" } + """) + val result = KiloCliDataParser.parseChatEvent("session.idle", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionIdle) + assertEquals("ses_1", result.sessionID) + } - @Test - fun `parseSession - minimal session response`() { - val raw = """{ - "id": "ses_min", - "projectID": "proj_2", - "directory": "/tmp", - "title": "", - "version": "0", - "time": { "created": 0.0, "updated": 0.0 } - }""" + @Test + fun `parseChatEvent - session compacted`() { + val data = globalEvent(""" + "type": "session.compacted", + "properties": { "sessionID": "ses_1" } + """) + val result = KiloCliDataParser.parseChatEvent("session.compacted", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionCompacted) + } - val result = KiloCliDataParser.parseSession(raw) - assertEquals("ses_min", result.id) - assertNull(result.summary) - } + @Test + fun `parseChatEvent - session updated`() { + val data = globalEvent(""" + "type": "session.updated", + "properties": { + "sessionID": "ses_1", + "info": { + "id": "ses_1", + "projectID": "proj_1", + "directory": "/tmp/project", + "title": "Updated title", + "version": "1", + "time": { "created": 1.0, "updated": 2.0 }, + "summary": { "additions": 3, "deletions": 1, "files": 2 } + } + } + """) - @Test - fun `parseCloudSessions maps cloud session list`() { - val raw = """{ - "cliSessions": [ - {"session_id":"cloud_1","title":"Cloud One","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","version":2}, - {"session_id":"cloud_2","title":null,"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-04T00:00:00Z","version":3.5,"extra":true} - ], - "nextCursor": "cursor_2" - }""" + val result = KiloCliDataParser.parseChatEvent("session.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("Updated title", result.session.title) + assertEquals(2, result.session.summary?.files) + } - val result = KiloCliDataParser.parseCloudSessions(raw) + @Test + fun `parseChatEvent - session diff`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] + } + """) - assertEquals(2, result.sessions.size) - assertEquals("cloud_1", result.sessions[0].id) - assertEquals("Cloud One", result.sessions[0].title) - assertEquals("2026-01-02T00:00:00Z", result.sessions[0].updatedAt) - assertEquals(2.0, result.sessions[0].version) - assertNull(result.sessions[1].title) - assertEquals("cursor_2", result.nextCursor) - } + val result = KiloCliDataParser.parseChatEvent("session.diff", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionDiffChanged) + assertEquals(1, result.diff.size) + assertEquals("src/A.kt", result.diff[0].file) + assertEquals(3, result.diff[0].additions) + } - @Test - fun `parseCloudSessions tolerates malformed response`() { - assertEquals(emptyList(), KiloCliDataParser.parseCloudSessions("not json").sessions) - assertNull(KiloCliDataParser.parseCloudSessions("{}").nextCursor) - } + @Test + fun `parseChatEvent - session diff clamps large counts`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}] + } + """) - // ================================================================ - // parseMessages - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged + assertEquals(Int.MAX_VALUE, result.diff[0].additions) + assertEquals(Int.MAX_VALUE, result.diff[0].deletions) + } - @Test - fun `parseMessages - empty array`() { - assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]")) - } + @Test + fun `parseChatEvent - todo updated`() { + val data = globalEvent(""" + "type": "todo.updated", + "properties": { + "sessionID": "ses_1", + "todos": [ + {"content": "Write tests", "status": "in_progress", "priority": "high"}, + {"content": "Review PR", "status": "pending", "priority": "medium"} + ] + } + """) - @Test - fun `parseMessages - user and assistant messages`() { - val raw = """[ - { - "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, - "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] - }, - { - "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, - "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] - } - ]""" + val result = KiloCliDataParser.parseChatEvent("todo.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TodoUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals(2, result.todos.size) + assertEquals("Write tests", result.todos[0].content) + assertEquals("high", result.todos[0].priority) + } - val result = KiloCliDataParser.parseMessages(raw) - assertEquals(2, result.size) - assertEquals("user", result[0].info.role) - assertEquals("Hello", result[0].parts[0].text) - assertEquals("assistant", result[1].info.role) - assertEquals("Hi there", result[1].parts[0].text) - } + // ---- session status events ---- - @Test - fun `parseMessages - message with tool parts`() { - val raw = """[{ - "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, - "parts": [{ - "id": "p1", - "sessionID": "s1", - "messageID": "m1", - "type": "tool", - "tool": "read_file", - "state": { "status": "completed", "title": "Read file.txt" } - }] - }]""" + @Test + fun `parseChatEvent - session status idle`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { "sessionID": "ses_1", "status": {"type": "idle"} } + """) - val result = KiloCliDataParser.parseMessages(raw) - assertEquals(1, result.size) - val part = result[0].parts[0] - assertEquals("tool", part.type) - assertEquals("read_file", part.tool) - assertEquals("completed", part.state) - assertEquals("Read file.txt", part.title) - } + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("idle", result.status.type) + assertNull(result.status.attempt) + assertNull(result.status.requestID) + } - @Test - fun `parseMessages - step finish part with tokens`() { - val raw = """[{ - "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, - "parts": [{ - "id": "p1", - "sessionID": "s1", - "messageID": "m1", - "type": "step-finish", - "reason": "stop", - "cost": 0.005, - "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } } - }] - }]""" + @Test + fun `parseChatEvent - session status retry with attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000} + } + """) - val part = KiloCliDataParser.parseMessages(raw)[0].parts[0] - assertEquals("step-finish", part.type) - assertEquals("stop", part.reason) - assertEquals(0.005, part.cost) - assertEquals(100L, part.tokens?.input) - assertEquals(50L, part.tokens?.output) - assertEquals(10L, part.tokens?.reasoning) - assertEquals(20L, part.tokens?.cacheRead) - assertEquals(5L, part.tokens?.cacheWrite) - } + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("retry", result.status.type) + assertEquals("Retrying...", result.status.message) + assertEquals(2, result.status.attempt) + assertEquals(5000L, result.status.next) + } - @Test - fun `parseMessages - malformed JSON returns empty`() { - assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) - } + @Test + fun `parseChatEvent - session status clamps large attempt`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807} + } + """) - @Test - fun `parseMessages - message with tokens`() { - val raw = """[{ - "info": { - "id": "m1", "sessionID": "s1", "role": "assistant", - "time": { "created": 1.0, "completed": 2.0 }, - "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }, - "cost": 0.005 - }, - "parts": [] - }]""" + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals(Int.MAX_VALUE, result.status.attempt) + assertEquals(Long.MAX_VALUE, result.status.next) + } - val result = KiloCliDataParser.parseMessages(raw) - val info = result[0].info - assertNotNull(info.tokens) - assertEquals(100L, info.tokens?.input) - assertEquals(50L, info.tokens?.output) - assertEquals(10L, info.tokens?.reasoning) - assertEquals(20L, info.tokens?.cacheRead) - assertEquals(5L, info.tokens?.cacheWrite) - assertEquals(0.005, info.cost) - assertEquals(2.0, info.time.completed) - } + @Test + fun `parseChatEvent - session status offline with requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "offline", "message": "No network", "requestID": "req_abc"} + } + """) - // ================================================================ - // parseModelState / buildModelStateJson - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("offline", result.status.type) + assertEquals("No network", result.status.message) + assertEquals("req_abc", result.status.requestID) + } - @Test - fun `parseModelState - parses favorites`() { - val result = KiloCliDataParser.parseModelState( - """{"favorite":[{"providerID":"kilo","modelID":"auto"},{"providerID":"openai","modelID":"gpt"}]}""", - ) + // ---- permission / question events ---- - assertEquals(listOf("kilo/auto", "openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseChatEvent - permission asked`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_1", + "sessionID": "ses_1", + "permission": "edit", + "patterns": ["*.kt"], + "always": [], + "metadata": {"file": "src/A.kt"}, + "tool": {"messageID": "msg_1", "callID": "call_1"} + } + """) - @Test - fun `parseModelState - parses recent selections`() { - val result = KiloCliDataParser.parseModelState( - """{"recent":[{"providerID":"anthropic","modelID":"claude"},{"providerID":"openai","modelID":"gpt"}]}""", - ) + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.request.id) + assertEquals("edit", result.request.permission) + assertEquals(listOf("*.kt"), result.request.patterns) + assertEquals("src/A.kt", result.request.metadata["file"]) + assertEquals("msg_1", result.request.tool?.messageID) + } - assertEquals(listOf("anthropic/claude", "openai/gpt"), result.recent.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseChatEvent - permission replied`() { + val data = globalEvent(""" + "type": "permission.replied", + "properties": { "sessionID": "ses_1", "requestID": "perm_1" } + """) - @Test - fun `parseModelState - parses model selections and variants`() { - val result = KiloCliDataParser.parseModelState( - """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"variant":{"kilo/auto":"medium"}}""", - ) + val result = KiloCliDataParser.parseChatEvent("permission.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionReplied) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.requestID) + } - assertEquals("kilo", result.model["code"]?.providerID) - assertEquals("auto", result.model["code"]?.modelID) - assertEquals("medium", result.variant["kilo/auto"]) - } + @Test + fun `parseChatEvent - question asked`() { + val data = globalEvent(""" + "type": "question.asked", + "properties": { + "id": "q_1", + "sessionID": "ses_1", + "questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}], + "tool": null + } + """) - @Test - fun `parseModelState - drops malformed favorites`() { - val result = KiloCliDataParser.parseModelState( - """{"favorite":[{"providerID":"kilo"},false,{"providerID":"openai","modelID":"gpt"}]}""", - ) + val result = KiloCliDataParser.parseChatEvent("question.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("q_1", result.request.id) + assertEquals(1, result.request.questions.size) + assertEquals("Pick one", result.request.questions[0].question) + assertEquals("A", result.request.questions[0].options[0].label) + } - assertEquals(listOf("openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseChatEvent - question replied`() { + val data = globalEvent(""" + "type": "question.replied", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) - @Test - fun `parseModelState - malformed inputs return empty favorites`() { - for (raw in listOf("", "not-json", "[]", "42", "null")) { - assertTrue(KiloCliDataParser.parseModelState(raw).favorite.isEmpty(), raw) + val result = KiloCliDataParser.parseChatEvent("question.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionReplied) + assertEquals("q_1", result.requestID) + } + + @Test + fun `parseChatEvent - question rejected`() { + val data = globalEvent(""" + "type": "question.rejected", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("question.rejected", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionRejected) + assertEquals("q_1", result.requestID) + } + + // ---- error cases ---- + + @Test + fun `parseChatEvent - unknown type returns null`() { + val data = globalEvent(""" + "type": "some.unknown.event", + "properties": { "sessionID": "ses_1" } + """) + assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data)) + } + + @Test + fun `parseChatEvent - malformed JSON returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json")) + } + + @Test + fun `parseChatEvent - missing properties returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}""")) + } + + @Test + fun `parseChatEvent - missing sessionID returns null`() { + val data = globalEvent(""" + "type": "message.updated", + "properties": { "info": { "id": "msg_1", "role": "user", "time": {} } } + """) + assertNull(KiloCliDataParser.parseChatEvent("message.updated", data)) + } + + // ---- parseSessionStatus ---- + + @Test + fun `parseSessionStatus - valid status event`() { + val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_abc", result.first) + assertEquals("busy", result.second.type) + assertEquals("Running...", result.second.message) + } + + @Test + fun `parseSessionStatus - missing sessionID returns null`() { + val data = """{"status":{"type":"idle"}}""" + assertNull(KiloCliDataParser.parseSessionStatus(data)) + } + + @Test + fun `parseSessionStatus - missing status defaults to idle`() { + val data = """{"sessionID":"ses_xyz"}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("idle", result.second.type) + assertNull(result.second.message) + } + + @Test + fun `parseSessionStatus - retry preserves attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_retry", + "status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_retry", result.first) + assertEquals("retry", result.second.type) + assertEquals(3, result.second.attempt) + assertEquals(10000L, result.second.next) + } + + @Test + fun `parseSessionStatus - offline preserves requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_off", + "status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("req_xyz", result.second.requestID) + } + + // ---- parsePermissionRequests / parseQuestionRequests ---- + + @Test + fun `parsePermissionRequests - parses list`() { + val raw = """[ + {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} + ]""" + val result = KiloCliDataParser.parsePermissionRequests(raw) + assertEquals(1, result.size) + assertEquals("p1", result[0].id) + assertEquals("edit", result[0].permission) + } + + @Test + fun `parsePermissionRequests - empty list`() { + assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]")) + } + + @Test + fun `parseQuestionRequests - parses list`() { + val raw = """[ + {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} + ]""" + val result = KiloCliDataParser.parseQuestionRequests(raw) + assertEquals(1, result.size) + assertEquals("q1", result[0].id) } } - @Test - fun `parseModelState - drops malformed model selections and variants`() { - val result = KiloCliDataParser.parseModelState( - """{"model":{"bad":false,"ok":{"providerID":"kilo","modelID":"auto"}},"variant":{"":"low","kilo/auto":false,"openai/gpt":"high"}}""", - ) + // ================================================================ + // Group 2 — HTTP response parsing + // ================================================================ - assertEquals(listOf("ok"), result.model.keys.toList()) - assertEquals(mapOf("openai/gpt" to "high"), result.variant) - } + @Nested + inner class HttpResponses { - @Test - fun `buildModelStateJson - preserves unrelated keys and replaces favorites`() { - val raw = """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"recent":[{"providerID":"old","modelID":"recent"}],"variant":{"kilo/auto":"fast"},"extra":true,"favorite":[]}""" - val result = KiloCliDataParser.buildModelStateJson(raw, listOf(ModelSelectionDto("anthropic", "claude"))) + // ---- parseSession ---- - assertTrue(result.contains("\"model\""), result) - assertTrue(result.contains("\"recent\""), result) - assertTrue(result.contains("\"variant\""), result) - assertTrue(result.contains("\"extra\""), result) - assertEquals(listOf("anthropic/claude"), KiloCliDataParser.parseModelState(result).favorite.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseSession - full session response`() { + val raw = """{ + "id": "ses_abc", + "projectID": "proj_1", + "directory": "/tmp/project", + "parentID": null, + "title": "Test session", + "version": "1", + "time": { "created": 1000.0, "updated": 2000.0 }, + "summary": { "additions": 10, "deletions": 5, "files": 3 } + }""" - @Test - fun `buildModelStateJson - writes model selections and variants`() { - val raw = """{"recent":[],"extra":true}""" - val result = KiloCliDataParser.buildModelStateJson( - raw, - ModelStateDto( - model = mapOf("code" to ModelSelectionDto("kilo", "auto")), - variant = mapOf("kilo/auto" to "medium"), - recent = listOf(ModelSelectionDto("anthropic", "claude")), - ), - ) + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_abc", result.id) + assertEquals("proj_1", result.projectID) + assertEquals("/tmp/project", result.directory) + assertNull(result.parentID) + assertEquals("Test session", result.title) + assertEquals(1000.0, result.time.created) + assertEquals(2000.0, result.time.updated) + assertNotNull(result.summary) + assertEquals(10, result.summary?.additions) + assertEquals(5, result.summary?.deletions) + assertEquals(3, result.summary?.files) + } - val state = KiloCliDataParser.parseModelState(result) - assertEquals("auto", state.model["code"]?.modelID) - assertEquals("medium", state.variant["kilo/auto"]) - assertEquals(listOf("anthropic/claude"), state.recent.map { "${it.providerID}/${it.modelID}" }) - assertTrue(result.contains("\"extra\""), result) + @Test + fun `parseSession - minimal session response`() { + val raw = """{ + "id": "ses_min", + "projectID": "proj_2", + "directory": "/tmp", + "title": "", + "version": "0", + "time": { "created": 0.0, "updated": 0.0 } + }""" + + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_min", result.id) + assertNull(result.summary) + } + + // ---- parseMessages ---- + + @Test + fun `parseMessages - empty array`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]")) + } + + @Test + fun `parseMessages - user and assistant messages`() { + val raw = """[ + { + "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] + }, + { + "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, + "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] + } + ]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(2, result.size) + assertEquals("user", result[0].info.role) + assertEquals("Hello", result[0].parts[0].text) + assertEquals("assistant", result[1].info.role) + assertEquals("Hi there", result[1].parts[0].text) + } + + @Test + fun `parseMessages - message with tool parts`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", + "type": "tool", + "tool": "read_file", + "state": { "status": "completed", "title": "Read file.txt" } + }] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(1, result.size) + val part = result[0].parts[0] + assertEquals("tool", part.type) + assertEquals("read_file", part.tool) + assertEquals("completed", part.state) + assertEquals("Read file.txt", part.title) + } + + @Test + fun `parseMessages - step finish part with tokens`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", + "type": "step-finish", + "reason": "stop", + "cost": 0.005, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } } + }] + }]""" + + val part = KiloCliDataParser.parseMessages(raw)[0].parts[0] + assertEquals("step-finish", part.type) + assertEquals("stop", part.reason) + assertEquals(0.005, part.cost) + assertEquals(100L, part.tokens?.input) + assertEquals(50L, part.tokens?.output) + assertEquals(10L, part.tokens?.reasoning) + assertEquals(20L, part.tokens?.cacheRead) + assertEquals(5L, part.tokens?.cacheWrite) + } + + @Test + fun `parseMessages - message with tokens`() { + val raw = """[{ + "info": { + "id": "m1", "sessionID": "s1", "role": "assistant", + "time": { "created": 1.0, "completed": 2.0 }, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }, + "cost": 0.005 + }, + "parts": [] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + val info = result[0].info + assertNotNull(info.tokens) + assertEquals(100L, info.tokens?.input) + assertEquals(50L, info.tokens?.output) + assertEquals(10L, info.tokens?.reasoning) + assertEquals(20L, info.tokens?.cacheRead) + assertEquals(5L, info.tokens?.cacheWrite) + assertEquals(0.005, info.cost) + assertEquals(2.0, info.time.completed) + } + + @Test + fun `parseMessages - malformed JSON returns empty`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) + } + + // ---- parseCloudSessions ---- + + @Test + fun `parseCloudSessions maps cloud session list`() { + val raw = """{ + "cliSessions": [ + {"session_id":"cloud_1","title":"Cloud One","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","version":2}, + {"session_id":"cloud_2","title":null,"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-04T00:00:00Z","version":3.5,"extra":true} + ], + "nextCursor": "cursor_2" + }""" + + val result = KiloCliDataParser.parseCloudSessions(raw) + + assertEquals(2, result.sessions.size) + assertEquals("cloud_1", result.sessions[0].id) + assertEquals("Cloud One", result.sessions[0].title) + assertEquals("2026-01-02T00:00:00Z", result.sessions[0].updatedAt) + assertEquals(2.0, result.sessions[0].version) + assertNull(result.sessions[1].title) + assertEquals("cursor_2", result.nextCursor) + } + + @Test + fun `parseCloudSessions tolerates malformed response`() { + assertEquals(emptyList(), KiloCliDataParser.parseCloudSessions("not json").sessions) + assertNull(KiloCliDataParser.parseCloudSessions("{}").nextCursor) + } + + // ---- parseProviders ---- + + @Test + fun `parseProviders - maps provider id, name, source, connected, and defaults`() { + val raw = """{ + "all": [{"id":"anthropic","name":"Anthropic","source":"api","env":[],"options":{},"models":{}}], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""" + + val result = KiloCliDataParser.parseProviders(raw) + + assertEquals(1, result.providers.size) + assertEquals("anthropic", result.providers[0].id) + assertEquals("Anthropic", result.providers[0].name) + assertEquals("api", result.providers[0].source) + assertEquals(listOf("anthropic"), result.connected) + assertEquals(mapOf("code" to "anthropic/claude-4"), result.defaults) + } + + @Test + fun `parseProviders - maps model capabilities, limit, and recommendedIndex`() { + val raw = """{ + "all": [{ + "id": "anthropic", "name": "Anthropic", "source": "api", "env": [], "options": {}, + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "capabilities": { + "temperature": true, "reasoning": true, + "attachment": true, "toolcall": true + }, + "limit": {"context": 200000, "input": 100000, "output": 16000}, + "status": "active", + "recommendedIndex": 2, + "variants": {"high": {}, "low": {}, "medium": {}}, + "options": {}, "headers": {} + } + } + }], + "default": {}, "connected": [] + }""" + + val provider = KiloCliDataParser.parseProviders(raw).providers[0] + val model = provider.models["claude-4"] + assertNotNull(model) + assertEquals("claude-4", model.id) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.temperature) + assertTrue(model.toolCall) + assertEquals("active", model.status) + assertEquals(2.0, model.recommendedIndex) + assertEquals(200000L, model.limit?.context) + assertEquals(100000L, model.limit?.input) + assertEquals(16000L, model.limit?.output) + } + + @Test + fun `parseProviders - orders variants by effort rank then name`() { + val raw = """{ + "all": [{ + "id": "p", "name": "P", "source": "api", "env": [], "options": {}, + "models": { + "m": { + "capabilities": {}, "options": {}, "headers": {}, + "variants": {"high": {}, "low": {}, "medium": {}} + } + } + }], + "default": {}, "connected": [] + }""" + + val model = KiloCliDataParser.parseProviders(raw).providers[0].models["m"] + assertNotNull(model) + assertEquals(listOf("low", "medium", "high"), model.variants) + } + + @Test + fun `parseProviders - missing collections default to empty`() { + val result = KiloCliDataParser.parseProviders("""{"all":[],"default":{},"connected":[]}""") + assertEquals(emptyList(), result.providers) + assertEquals(emptyList(), result.connected) + assertEquals(emptyMap(), result.defaults) + } + + @Test + fun `parseProviders - model boolean capabilities default to false`() { + val raw = """{ + "all": [{ + "id": "p", "name": "P", "source": "api", "env": [], "options": {}, + "models": { "m": { "capabilities": {}, "options": {}, "headers": {} } } + }], + "default": {}, "connected": [] + }""" + + val model = KiloCliDataParser.parseProviders(raw).providers[0].models["m"] + assertNotNull(model) + assertEquals(false, model.attachment) + assertEquals(false, model.reasoning) + assertEquals(false, model.temperature) + assertEquals(false, model.toolCall) + assertNull(model.limit) + } + + @Test + fun `parseProviders - throws for malformed JSON`() { + assertFailsWith { + KiloCliDataParser.parseProviders("not json") + } + } + + @Test + fun `parseProviders - throws for non-object JSON`() { + assertFailsWith { + KiloCliDataParser.parseProviders("""[1,2,3]""") + } + } + + // ---- parseCommands ---- + + @Test + fun `parseCommands - maps name, description, source, and hints`() { + val raw = """[ + {"name":"init","description":"guided AGENTS.md setup","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command"}, + {"name":"mcp-tool","template":"","hints":["${'$'}1","${'$'}2"],"source":"mcp"} + ]""" + + val result = KiloCliDataParser.parseCommands(raw) + + assertEquals(2, result.size) + assertEquals("init", result[0].name) + assertEquals("guided AGENTS.md setup", result[0].description) + assertEquals("command", result[0].source) + assertEquals(listOf("\$ARGUMENTS"), result[0].hints) + assertEquals("mcp", result[1].source) + assertEquals(listOf("\$1", "\$2"), result[1].hints) + } + + @Test + fun `parseCommands - ignores lazy template object without crashing`() { + // Regression: CLI serializes promise-backed templates as {} which used to + // crash JetBrains startup before parsing was moved to this parser. + val raw = """[ + {"name":"local-review","description":"local review","template":{},"hints":[],"source":"command"}, + {"name":"local-review-uncommitted","description":"local review (uncommitted)","template":{},"hints":[]} + ]""" + + val result = KiloCliDataParser.parseCommands(raw) + + assertEquals(2, result.size) + assertEquals("local-review", result[0].name) + assertEquals("local review", result[0].description) + assertEquals("command", result[0].source) + assertEquals(emptyList(), result[0].hints) + assertEquals("local-review-uncommitted", result[1].name) + } + + @Test + fun `parseCommands - ignores template when it is a string`() { + val raw = """[{"name":"review","template":"do a review of ${'$'}ARGUMENTS","hints":["${'$'}ARGUMENTS"]}]""" + val result = KiloCliDataParser.parseCommands(raw) + assertEquals(1, result.size) + assertEquals("review", result[0].name) + assertEquals(listOf("\$ARGUMENTS"), result[0].hints) + } + + @Test + fun `parseCommands - missing hints defaults to empty`() { + val raw = """[{"name":"nohints","template":"x"}]""" + val result = KiloCliDataParser.parseCommands(raw) + assertEquals(emptyList(), result[0].hints) + } + + @Test + fun `parseCommands - empty array`() { + assertEquals(emptyList(), KiloCliDataParser.parseCommands("[]")) + } + + // ---- parsePathState ---- + + @Test + fun `parsePathState - extracts state from valid path response`() { + val raw = """{"home":"/home/user","state":"/home/user/.local/state/kilo","config":"/home/user/.config/kilo","worktree":"/project","directory":"/project"}""" + assertEquals("/home/user/.local/state/kilo", KiloCliDataParser.parsePathState(raw)) + } + + @Test + fun `parsePathState - returns null for missing state field`() { + assertNull(KiloCliDataParser.parsePathState("""{"home":"/home/user"}""")) + } + + @Test + fun `parsePathState - returns null for malformed JSON`() { + assertNull(KiloCliDataParser.parsePathState("not json")) + } + + @Test + fun `parsePathState - returns null for non-string state value`() { + assertNull(KiloCliDataParser.parsePathState("""{"state":42}""")) + assertNull(KiloCliDataParser.parsePathState("""{"state":null}""")) + assertNull(KiloCliDataParser.parsePathState("""{"state":{}}""")) + } } // ================================================================ - // buildPromptJson + // Group 3 — Request / body builders and local model state // ================================================================ - @Test - fun `buildPromptJson - text only`() { - val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello"))) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result) - } + @Nested + inner class RequestBuilders { - @Test - fun `buildPromptJson - with model override`() { - val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "Hi")), - providerID = "anthropic", - modelID = "claude-4", - ) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}""")) - } + // ---- buildPromptJson ---- - @Test - fun `buildPromptJson - with agent`() { - val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "Hi")), - agent = "ask", - ) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains(""""agent":"ask"""")) - } + @Test + fun `buildPromptJson - text only`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello"))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result) + } - @Test - fun `buildPromptJson - with variant`() { - val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "Hi")), - variant = "medium", - ) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains(""""variant":"medium"""")) - } + @Test + fun `buildPromptJson - with model override`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + providerID = "anthropic", + modelID = "claude-4", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}""")) + } - @Test - fun `buildPromptJson - escapes special characters`() { - val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\""))) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) - } + @Test + fun `buildPromptJson - with messageID`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + messageID = "msg_1", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""messageID":"msg_1"""")) + } - @Test - fun `buildSummarizeJson - writes provider and model`() { - val result = KiloCliDataParser.buildSummarizeJson(ModelSelectionDto("anthropic", "claude-4")) + @Test + fun `buildPromptJson - with noReply`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + noReply = true, + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result) + } - assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", result) + @Test + fun `buildPromptJson - with agent`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + agent = "ask", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""agent":"ask"""")) + } + + @Test + fun `buildPromptJson - with variant`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + variant = "medium", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""variant":"medium"""")) + } + + @Test + fun `buildPromptJson - escapes special characters`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\""))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) + } + + // ---- buildSummarizeJson ---- + + @Test + fun `buildSummarizeJson - writes provider and model`() { + val result = KiloCliDataParser.buildSummarizeJson(ModelSelectionDto("anthropic", "claude-4")) + assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", result) + } + + // ---- buildConfigPartial ---- + + @Test + fun `buildConfigPartial - model only`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) + assertEquals("""{"model":"anthropic/claude-4"}""", result) + } + + @Test + fun `buildConfigPartial - agent and temperature`() { + val result = KiloCliDataParser.buildConfigPartial( + ConfigUpdateDto(agent = "code", temperature = 0.7) + ) + assertTrue(result.contains(""""default_agent":"code"""")) + assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) + } + + @Test + fun `buildConfigPartial - empty update`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) + assertEquals("{}", result) + } + + @Test + fun `buildConfigPartial - temperature without agent defaults to ask`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) + assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) + } + + // ---- buildPermissionReplyJson ---- + + @Test + fun `buildPermissionReplyJson - once reply`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once")) + assertEquals("""{"reply":"once"}""", result) + } + + @Test + fun `buildPermissionReplyJson - always reply with message`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved")) + assertTrue(result.contains(""""reply":"always"""")) + assertTrue(result.contains(""""message":"approved"""")) + } + + // ---- buildPermissionAlwaysRulesJson ---- + + @Test + fun `buildPermissionAlwaysRulesJson - approved list`() { + val result = KiloCliDataParser.buildPermissionAlwaysRulesJson( + PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList()) + ) + assertTrue(result.contains(""""approvedAlways":["src/**"]""")) + assertTrue(result.contains(""""deniedAlways":[]""")) + } + + // ---- buildQuestionReplyJson ---- + + @Test + fun `buildQuestionReplyJson - single question single answer`() { + val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A")))) + assertEquals("""{"answers":[["A"]]}""", result) + } + + @Test + fun `buildQuestionReplyJson - multiple questions`() { + val result = KiloCliDataParser.buildQuestionReplyJson( + QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes"))) + ) + assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result) + } + + // ---- parseModelState / buildModelStateJson ---- + + @Test + fun `parseModelState - parses favorites`() { + val result = KiloCliDataParser.parseModelState( + """{"favorite":[{"providerID":"kilo","modelID":"auto"},{"providerID":"openai","modelID":"gpt"}]}""", + ) + assertEquals(listOf("kilo/auto", "openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `parseModelState - parses recent selections`() { + val result = KiloCliDataParser.parseModelState( + """{"recent":[{"providerID":"anthropic","modelID":"claude"},{"providerID":"openai","modelID":"gpt"}]}""", + ) + assertEquals(listOf("anthropic/claude", "openai/gpt"), result.recent.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `parseModelState - parses model selections and variants`() { + val result = KiloCliDataParser.parseModelState( + """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"variant":{"kilo/auto":"medium"}}""", + ) + assertEquals("kilo", result.model["code"]?.providerID) + assertEquals("auto", result.model["code"]?.modelID) + assertEquals("medium", result.variant["kilo/auto"]) + } + + @Test + fun `parseModelState - drops malformed favorites`() { + val result = KiloCliDataParser.parseModelState( + """{"favorite":[{"providerID":"kilo"},false,{"providerID":"openai","modelID":"gpt"}]}""", + ) + assertEquals(listOf("openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `parseModelState - malformed inputs return empty favorites`() { + for (raw in listOf("", "not-json", "[]", "42", "null")) { + assertTrue(KiloCliDataParser.parseModelState(raw).favorite.isEmpty(), raw) + } + } + + @Test + fun `parseModelState - drops malformed model selections and variants`() { + val result = KiloCliDataParser.parseModelState( + """{"model":{"bad":false,"ok":{"providerID":"kilo","modelID":"auto"}},"variant":{"":"low","kilo/auto":false,"openai/gpt":"high"}}""", + ) + assertEquals(listOf("ok"), result.model.keys.toList()) + assertEquals(mapOf("openai/gpt" to "high"), result.variant) + } + + @Test + fun `buildModelStateJson - preserves unrelated keys and replaces favorites`() { + val raw = """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"recent":[{"providerID":"old","modelID":"recent"}],"variant":{"kilo/auto":"fast"},"extra":true,"favorite":[]}""" + val result = KiloCliDataParser.buildModelStateJson(raw, listOf(ModelSelectionDto("anthropic", "claude"))) + + assertTrue(result.contains("\"model\""), result) + assertTrue(result.contains("\"recent\""), result) + assertTrue(result.contains("\"variant\""), result) + assertTrue(result.contains("\"extra\""), result) + assertEquals(listOf("anthropic/claude"), KiloCliDataParser.parseModelState(result).favorite.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `buildModelStateJson - writes model selections and variants`() { + val raw = """{"recent":[],"extra":true}""" + val result = KiloCliDataParser.buildModelStateJson( + raw, + ModelStateDto( + model = mapOf("code" to ModelSelectionDto("kilo", "auto")), + variant = mapOf("kilo/auto" to "medium"), + recent = listOf(ModelSelectionDto("anthropic", "claude")), + ), + ) + + val state = KiloCliDataParser.parseModelState(result) + assertEquals("auto", state.model["code"]?.modelID) + assertEquals("medium", state.variant["kilo/auto"]) + assertEquals(listOf("anthropic/claude"), state.recent.map { "${it.providerID}/${it.modelID}" }) + assertTrue(result.contains("\"extra\""), result) + } } // ================================================================ - // buildConfigPartial + // parsePermissionRequest — rich metadata // ================================================================ @Test - fun `buildConfigPartial - model only`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) - assertEquals("""{"model":"anthropic/claude-4"}""", result) - } - - @Test - fun `buildConfigPartial - agent and temperature`() { - val result = KiloCliDataParser.buildConfigPartial( - ConfigUpdateDto(agent = "code", temperature = 0.7) - ) - assertTrue(result.contains(""""default_agent":"code"""")) - assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) - } - - @Test - fun `buildConfigPartial - empty update`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) - assertEquals("{}", result) - } - - @Test - fun `buildConfigPartial - temperature without agent defaults to ask`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) - assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) - } - - // ================================================================ - // parseChatEvent — permission / question events - // ================================================================ - - @Test - fun `parseChatEvent - permission asked`() { + fun `parsePermissionRequest - command metadata extracted`() { val data = globalEvent(""" "type": "permission.asked", "properties": { - "id": "perm_1", + "id": "perm_cmd", "sessionID": "ses_1", - "permission": "edit", - "patterns": ["*.kt"], + "permission": "bash", + "patterns": [], "always": [], - "metadata": {"file": "src/A.kt"}, - "tool": {"messageID": "msg_1", "callID": "call_1"} + "metadata": {"command": "git status --short"} } """) val result = KiloCliDataParser.parseChatEvent("permission.asked", data) assertNotNull(result) - assertTrue(result is ChatEventDto.PermissionAsked) - assertEquals("ses_1", result.sessionID) - assertEquals("perm_1", result.request.id) - assertEquals("edit", result.request.permission) - assertEquals(listOf("*.kt"), result.request.patterns) - assertEquals("src/A.kt", result.request.metadata["file"]) - assertEquals("msg_1", result.request.tool?.messageID) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals("git status --short", asked.request.command) + assertEquals("git status --short", asked.request.metadata["command"]) } @Test - fun `parseChatEvent - permission replied`() { + fun `parsePermissionRequest - diff and filepath fallback`() { val data = globalEvent(""" - "type": "permission.replied", - "properties": { "sessionID": "ses_1", "requestID": "perm_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("permission.replied", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PermissionReplied) - assertEquals("ses_1", result.sessionID) - assertEquals("perm_1", result.requestID) - } - - @Test - fun `parseChatEvent - question asked`() { - val data = globalEvent(""" - "type": "question.asked", + "type": "permission.asked", "properties": { - "id": "q_1", + "id": "perm_diff", "sessionID": "ses_1", - "questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}], - "tool": null + "permission": "edit", + "patterns": [], + "always": [], + "metadata": {"filepath": "src/App.kt", "diff": "@@ -1 +1 @@"} } """) - val result = KiloCliDataParser.parseChatEvent("question.asked", data) + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) assertNotNull(result) - assertTrue(result is ChatEventDto.QuestionAsked) - assertEquals("ses_1", result.sessionID) - assertEquals("q_1", result.request.id) - assertEquals(1, result.request.questions.size) - assertEquals("Pick one", result.request.questions[0].question) - assertEquals("A", result.request.questions[0].options[0].label) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals("src/App.kt", asked.request.filePath) + assertEquals(1, asked.request.fileDiffs.size) + assertEquals("src/App.kt", asked.request.fileDiffs[0].file) + assertEquals("@@ -1 +1 @@", asked.request.fileDiffs[0].patch) } @Test - fun `parseChatEvent - question replied`() { + fun `parsePermissionRequest - filediff object`() { val data = globalEvent(""" - "type": "question.replied", - "properties": { "sessionID": "ses_1", "requestID": "q_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("question.replied", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.QuestionReplied) - assertEquals("q_1", result.requestID) - } - - @Test - fun `parseChatEvent - question rejected`() { - val data = globalEvent(""" - "type": "question.rejected", - "properties": { "sessionID": "ses_1", "requestID": "q_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("question.rejected", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.QuestionRejected) - assertEquals("q_1", result.requestID) - } - - // ================================================================ - // parseChatEvent — session.status with retry/offline detail - // ================================================================ - - @Test - fun `parseChatEvent - session status idle`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { "sessionID": "ses_1", "status": {"type": "idle"} } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals("idle", result.status.type) - assertNull(result.status.attempt) - assertNull(result.status.requestID) - } - - @Test - fun `parseChatEvent - session status retry with attempt and next`() { - val data = globalEvent(""" - "type": "session.status", + "type": "permission.asked", "properties": { + "id": "perm_filediff", "sessionID": "ses_1", - "status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000} - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals("retry", result.status.type) - assertEquals("Retrying...", result.status.message) - assertEquals(2, result.status.attempt) - assertEquals(5000L, result.status.next) - } - - @Test - fun `parseChatEvent - session status clamps large attempt`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_1", - "status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807} - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals(Int.MAX_VALUE, result.status.attempt) - assertEquals(Long.MAX_VALUE, result.status.next) - } - - @Test - fun `parseChatEvent - session status offline with requestID`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_1", - "status": {"type": "offline", "message": "No network", "requestID": "req_abc"} - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals("offline", result.status.type) - assertEquals("No network", result.status.message) - assertEquals("req_abc", result.status.requestID) - } - - // ================================================================ - // parseChatEvent — message.part.removed - // ================================================================ - - @Test - fun `parseChatEvent - message part removed`() { - val data = globalEvent(""" - "type": "message.part.removed", - "properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("message.part.removed", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartRemoved) - assertEquals("ses_1", result.sessionID) - assertEquals("msg_1", result.messageID) - assertEquals("part_1", result.partID) - } - - // ================================================================ - // parseChatEvent — todo.updated - // ================================================================ - - @Test - fun `parseChatEvent - todo updated`() { - val data = globalEvent(""" - "type": "todo.updated", - "properties": { - "sessionID": "ses_1", - "todos": [ - {"content": "Write tests", "status": "in_progress", "priority": "high"}, - {"content": "Review PR", "status": "pending", "priority": "medium"} - ] - } - """) - - val result = KiloCliDataParser.parseChatEvent("todo.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.TodoUpdated) - assertEquals("ses_1", result.sessionID) - assertEquals(2, result.todos.size) - assertEquals("Write tests", result.todos[0].content) - assertEquals("high", result.todos[0].priority) - } - - // ================================================================ - // parseChatEvent — session.idle / session.compacted / session.diff - // ================================================================ - - @Test - fun `parseChatEvent - session idle`() { - val data = globalEvent(""" - "type": "session.idle", - "properties": { "sessionID": "ses_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("session.idle", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionIdle) - assertEquals("ses_1", result.sessionID) - } - - @Test - fun `parseChatEvent - session compacted`() { - val data = globalEvent(""" - "type": "session.compacted", - "properties": { "sessionID": "ses_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("session.compacted", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionCompacted) - } - - @Test - fun `parseChatEvent - session updated`() { - val data = globalEvent(""" - "type": "session.updated", - "properties": { - "sessionID": "ses_1", - "info": { - "id": "ses_1", - "projectID": "proj_1", - "directory": "/tmp/project", - "title": "Updated title", - "version": "1", - "time": { "created": 1.0, "updated": 2.0 }, - "summary": { "additions": 3, "deletions": 1, "files": 2 } + "permission": "edit", + "patterns": [], + "always": [], + "metadata": { + "filediff": { + "file": "src/A.kt", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 1 + } } } """) - val result = KiloCliDataParser.parseChatEvent("session.updated", data) + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) assertNotNull(result) - assertTrue(result is ChatEventDto.SessionUpdated) - assertEquals("ses_1", result.sessionID) - assertEquals("Updated title", result.session.title) - assertEquals(2, result.session.summary?.files) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals(1, asked.request.fileDiffs.size) + assertEquals("src/A.kt", asked.request.fileDiffs[0].file) + assertEquals("@@ -1 +1 @@", asked.request.fileDiffs[0].patch) + assertEquals(1, asked.request.fileDiffs[0].additions) + assertEquals(1, asked.request.fileDiffs[0].deletions) } @Test - fun `parseChatEvent - session diff`() { + fun `parsePermissionRequest - files array`() { val data = globalEvent(""" - "type": "session.diff", + "type": "permission.asked", "properties": { + "id": "perm_files", "sessionID": "ses_1", - "diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.diff", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionDiffChanged) - assertEquals(1, result.diff.size) - assertEquals("src/A.kt", result.diff[0].file) - assertEquals(3, result.diff[0].additions) - } - - @Test - fun `parseChatEvent - session diff clamps large counts`() { - val data = globalEvent(""" - "type": "session.diff", - "properties": { - "sessionID": "ses_1", - "diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}] - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged - assertEquals(Int.MAX_VALUE, result.diff[0].additions) - assertEquals(Int.MAX_VALUE, result.diff[0].deletions) - } - - // ================================================================ - // parseChatEvent — part with callID - // ================================================================ - - @Test - fun `parseChatEvent - part updated with callID`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_1", - "sessionID": "ses_1", - "messageID": "msg_1", - "type": "tool", - "tool": "bash", - "callID": "call_abc", - "state": { "status": "running" } + "permission": "edit", + "patterns": [], + "always": [], + "metadata": { + "files": [ + {"relativePath": "src/A.kt", "patch": "@@", "additions": 2, "deletions": 0}, + {"filePath": "src/B.kt", "patch": "@@", "additions": 0, "deletions": 3} + ] } } """) - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated - assertEquals("call_abc", result.part.callID) - assertEquals("bash", result.part.tool) + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertEquals(2, asked.request.fileDiffs.size) + assertEquals("src/A.kt", asked.request.fileDiffs[0].file) + assertEquals(2, asked.request.fileDiffs[0].additions) + assertEquals("src/B.kt", asked.request.fileDiffs[1].file) + assertEquals(3, asked.request.fileDiffs[1].deletions) } - // ================================================================ - // parseSessionStatus — full detail - // ================================================================ - @Test - fun `parseSessionStatus - retry preserves attempt and next`() { + fun `parsePermissionRequest - malformed files metadata returns empty diffs`() { val data = globalEvent(""" - "type": "session.status", + "type": "permission.asked", "properties": { - "sessionID": "ses_retry", - "status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000} + "id": "perm_bad", + "sessionID": "ses_1", + "permission": "edit", + "patterns": [], + "always": [], + "metadata": {"files": "not-an-array"} } """) - val result = KiloCliDataParser.parseSessionStatus(data) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) assertNotNull(result) - assertEquals("ses_retry", result.first) - assertEquals("retry", result.second.type) - assertEquals(3, result.second.attempt) - assertEquals(10000L, result.second.next) + val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked") + assertTrue(asked.request.fileDiffs.isEmpty()) } @Test - fun `parseSessionStatus - offline preserves requestID`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_off", - "status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"} - } - """) - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("req_xyz", result.second.requestID) - } - - // ================================================================ - // buildPermissionReplyJson - // ================================================================ - - @Test - fun `buildPermissionReplyJson - once reply`() { - val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once")) - assertEquals("""{"reply":"once"}""", result) - } - - @Test - fun `buildPermissionReplyJson - always reply with message`() { - val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved")) - assertTrue(result.contains(""""reply":"always"""")) - assertTrue(result.contains(""""message":"approved"""")) - } - - // ================================================================ - // buildPermissionAlwaysRulesJson - // ================================================================ - - @Test - fun `buildPermissionAlwaysRulesJson - approved list`() { - val result = KiloCliDataParser.buildPermissionAlwaysRulesJson( - PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList()) - ) - assertTrue(result.contains(""""approvedAlways":["src/**"]""")) - assertTrue(result.contains(""""deniedAlways":[]""")) - } - - // ================================================================ - // buildQuestionReplyJson - // ================================================================ - - @Test - fun `buildQuestionReplyJson - single question single answer`() { - val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A")))) - assertEquals("""{"answers":[["A"]]}""", result) - } - - @Test - fun `buildQuestionReplyJson - multiple questions`() { - val result = KiloCliDataParser.buildQuestionReplyJson( - QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes"))) - ) - assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result) - } - - // ================================================================ - // parsePermissionRequests / parseQuestionRequests - // ================================================================ - - @Test - fun `parsePermissionRequests - parses list`() { + fun `parsePermissionRequest - old json without new fields uses defaults`() { val raw = """[ {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} ]""" val result = KiloCliDataParser.parsePermissionRequests(raw) assertEquals(1, result.size) - assertEquals("p1", result[0].id) - assertEquals("edit", result[0].permission) - } - - @Test - fun `parsePermissionRequests - empty list`() { - assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]")) - } - - @Test - fun `parseQuestionRequests - parses list`() { - val raw = """[ - {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} - ]""" - val result = KiloCliDataParser.parseQuestionRequests(raw) - assertEquals(1, result.size) - assertEquals("q1", result[0].id) + assertNull(result[0].command) + assertTrue(result[0].rules.isEmpty()) + assertTrue(result[0].fileDiffs.isEmpty()) + assertNull(result[0].filePath) + assertNull(result[0].message) } // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index f40013e7ade..bdde10d818f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -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) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 13cc7e49567..6decdb82e85 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 8b5fef85265..af2d807656c 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 735d87eff95..c0b996948a7 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -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") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 52262d1ba8e..c126b91f2cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt new file mode 100644 index 00000000000..83cb7cd9025 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index c6e6030d0db..4918660bc25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9bf6aebd42c..f752e1b7822 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/PaidModelAuth.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/PaidModelAuth.kt new file mode 100644 index 00000000000..494cc4809f8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/PaidModelAuth.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 3c0da06ba82..a72f6fe55a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -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 = mutableMapOf() + private val childIds: MutableSet = 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, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt index ba6217af592..50f644fe327 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt @@ -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" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt index d6034eee7ca..efc9d568497 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt @@ -17,10 +17,12 @@ data class Permission( ) data class PermissionMeta( + val command: String? = null, val rules: List = emptyList(), val diff: String? = null, val filePath: String? = null, val fileDiff: PermissionFileDiff? = null, + val fileDiffs: List = emptyList(), val raw: Map = emptyMap(), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 156411aaf88..aaa5faef924 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -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 } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 48c8068068d..59b13882c34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -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) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 3d079596616..339c6638a85 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -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, private val history: () -> Unit = {}, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { - val view: CenterShrinkPanel = CenterShrinkPanel(this) + val view: Align = align(HAlign.CENTER, VAlign.CENTER) private val model = DefaultListModel() 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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt index 04acd9f6b44..10be5d2285e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt @@ -18,7 +18,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget { } override fun applyStyle(style: SessionEditorStyle) { - label.font = style.uiFont + label.font = style.regularFont revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 376816612de..5454737bc6c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -62,7 +62,7 @@ class ProgressPanel( } override fun applyStyle(style: SessionEditorStyle) { - label.font = style.uiFont + label.font = style.regularFont revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 9051105a649..4c5e49f4ae1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt new file mode 100644 index 00000000000..52bbba4eaf5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt new file mode 100644 index 00000000000..b4b6e4c71ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt @@ -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 { + 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, + 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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt new file mode 100644 index 00000000000..d30d3ca8803 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -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 = 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() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt new file mode 100644 index 00000000000..366bc5b4801 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -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) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt index e15a525fdd1..67939dacead 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 615f52df59e..cc7f31d1079 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index 1dc0ea9a5ca..a6c004b46fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -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() - } - } - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt index e2462f96d86..c5aaa89b63f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 9ba3f57bfd4..811847628f0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -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)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index d510a0fdebe..02daa43ca22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 6cfdc93961e..df7f84ec064 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index df406de4762..25435404d9b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt new file mode 100644 index 00000000000..83a658f528d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -0,0 +1,75 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.components.BorderLayoutPanel + +/** + * Retained inline view shown at the bottom of the transcript when a session + * enters [ai.kilocode.client.session.model.SessionState.LoginRequired]. + * + * Mirrors the anchored placement of [PermissionView] and [question.QuestionView]: + * it stays as a stable child inside [ai.kilocode.client.session.ui.SessionMessageListPanel] + * and is toggled visible/hidden via [show]/[hideView]. + */ +class LoginRequiredView( + private val openProfile: () -> Unit, + private val dismiss: () -> Unit, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { + + override val sessionViewKind = SessionView.Kind.Default + + private val card = BaseQuestionView() + + private val ID_DISMISS = "dismiss" + private val ID_OPEN = "open" + + init { + isOpaque = false + isVisible = false + + card.setHeader(KiloBundle.message("session.login.required.title")) + card.setActions(listOf( + BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.login.required.dismiss"), primary = false) { dismiss() }, + BaseQuestionView.Action(ID_OPEN, KiloBundle.message("session.login.required.button"), primary = true) { openProfile() }, + )) + + addToCenter(card) + } + + /** Make the view visible with [message] shown as the description. */ + @RequiresEdt + fun show(message: String) { + card.setDescription(message) + isVisible = true + refresh() + } + + /** Hide the view. */ + @RequiresEdt + fun hideView() { + if (!isVisible) return + isVisible = false + refresh() + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + card.applyStyle(style) + } + + // Test helpers — return generic JButton to keep SessionQuestionButton internal + internal fun openProfileButton() = card.actionButtonsForTest()[ID_OPEN]!! + internal fun dismissButton() = card.actionButtonsForTest()[ID_DISMISS]!! + + private fun refresh() { + revalidate() + repaint() + parent?.revalidate() + parent?.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index a01ca5838b6..e2db58c0c7e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.ui.RoundedLineBorder import com.intellij.util.ui.JBUI diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt deleted file mode 100644 index be38165e41a..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt +++ /dev/null @@ -1,100 +0,0 @@ -package ai.kilocode.client.session.views - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.model.Permission -import ai.kilocode.client.session.ui.SessionView -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.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.components.BorderLayoutPanel -import java.awt.BorderLayout - -/** - * Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel] - * at the end of the transcript when the session is in - * [ai.kilocode.client.session.model.SessionState.AwaitingPermission]. - * - * Unlike the old docked [ai.kilocode.client.session.ui.PermissionPanel], this view lives inside - * the scrollable transcript so the user can scroll through prior messages while a permission is pending. - */ -class PermissionView( - private val reply: (String, PermissionReplyDto) -> Unit, -) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { - override val sessionViewKind = SessionView.Kind.Default - - private var requestId: String? = null - private var style = SessionEditorStyle.current() - - init { - isOpaque = false - isVisible = false - } - - /** Populate the view for [permission] and make it visible. */ - fun show(permission: Permission) { - requestId = permission.id - val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" } - - removeAll() - - val card = BorderLayoutPanel() - card.isOpaque = true - card.background = SessionUiStyle.View.surface() - card.border = SessionUiStyle.View.card() - - card.add(panel { - row { - icon(AllIcons.General.Warning).gap(RightGap.SMALL) - label(KiloBundle.message("session.permission.title")).bold() - } - row { - label(KiloBundle.message("session.permission.meta", permission.name, patterns)) - } - val msg = permission.message - if (!msg.isNullOrBlank()) { - row { - comment(msg) - } - } - row { - button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL) - button(KiloBundle.message("session.permission.deny")) { decide("reject") } - }.layout(RowLayout.INDEPENDENT) - }.also { it.isOpaque = false }, BorderLayout.CENTER) - - add(card, BorderLayout.CENTER) - - isVisible = true - refresh() - } - - /** Hide this view and clear the active request id. */ - fun hideView() { - requestId = null - removeAll() - isVisible = false - refresh() - } - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - } - - private fun decide(value: String) { - val id = requestId ?: return - reply(id, PermissionReplyDto(reply = value)) - hideView() - } - - private fun refresh() { - revalidate() - repaint() - parent?.revalidate() - parent?.repaint() - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index bec8c6b6fad..7faf0e3ac9b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning 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 ai.kilocode.client.ui.md.MdView @@ -109,7 +110,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() { body.add(md.component, BorderLayout.CENTER) add(header, BorderLayout.NORTH) - if (canExpand()) add(scroll, BorderLayout.CENTER) sync() } @@ -122,7 +122,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() { md.set(source) changed = true } - changed = syncBody() || changed changed = sync() || changed if (changed) refresh() } @@ -131,8 +130,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() { if (delta.isEmpty()) return source += delta md.append(delta) - var changed = syncBody() - changed = sync() || changed + val changed = sync() if (changed || bodyVisible()) refresh() } @@ -209,12 +207,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() { return changed } - private fun syncBody(): Boolean { - if (!canExpand()) return collapse() - if (bodyVisible()) return false - return expand() - } - private fun setVisible(component: JBLabel, visible: Boolean): Boolean { if (component.isVisible == visible) return false component.isVisible = visible diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index e08880b9017..86ab85cdbec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import java.awt.BorderLayout diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index 39048dd38af..2cb3a4f30b4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState 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.icons.AllIcons diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 4d9483bbcc6..59c9b360dc2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.views.base.GenericView +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt new file mode 100644 index 00000000000..58c379568d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -0,0 +1,330 @@ +package ai.kilocode.client.session.views.base + +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.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Shared rounded background panel for session inline views that follow the + * question-view visual style: a card surface with a header text area, a + * description text area, an optional component above the header, and slots + * for view-specific content and a base-owned action-button footer. + * + * Both [ai.kilocode.client.session.views.question.QuestionView] and + * [ai.kilocode.client.session.views.LoginRequiredView] use this as their + * outer card shell so they share the same background, padding, and text + * styling without duplicating the setup. + * + * The column always contains (in order): optional top, header row with the + * header text, description text, optional content, optional action footer. + * Call [setTopPanel], [setHeaderIcon], [setHeader], [setDescription], + * [setContent], [setActions], or [setActionEnabled] to configure the card. + */ +class BaseQuestionView : RoundedContentPanel( + UiStyle.Gap.lg(), + UiStyle.Gap.pad(), +), SessionEditorStyleTarget { + + // ---- Action descriptor ---- + + /** + * Describes a button to render in the card's action footer. + * + * @param id Stable identifier so [setActionEnabled] can target a specific button. + * @param text Button label shown to the user. + * @param primary True → rendered as the platform default (accent) button. + * @param enabled Initial enabled state. + * @param handler Called when the button is clicked. + */ + data class Action( + val id: String, + val text: String, + val primary: Boolean, + val enabled: Boolean = true, + val handler: () -> Unit, + ) + + // ---- private state ---- + + private var style = SessionEditorStyle.current() + + private val tracked = mutableListOf>() + + private val header = object : JPanel(BorderLayout(UiStyle.Gap.sm(), 0)) { + override fun getMaximumSize(): Dimension { + val size = preferredSize + return Dimension(Int.MAX_VALUE, size.height) + } + }.apply { + isOpaque = false + alignmentX = Component.LEFT_ALIGNMENT + } + + private val icon = JBLabel().apply { + border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) + isVisible = false + } + + private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true) + private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false) + + private var top: JComponent? = null + private var content: JComponent? = null + + // action buttons keyed by id for enabled-state updates + private val actionButtons = mutableMapOf() + private var actionFooter: JComponent? = null + + private val col = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + } + + init { + header.add(icon, BorderLayout.WEST) + header.add(headerText, BorderLayout.CENTER) + addToCenter(col) + rebuildCol() + } + + // ---- public text API ---- + + /** + * Set the header text and, optionally, the description text in one call. + * Pass `null` or an empty string for [description] to hide the description row. + */ + @RequiresEdt + fun setHeader(text: String, description: String? = null) { + headerText.text = text + setDescription(description) + } + + /** + * Set or clear the description text below the header. + * The description row is visible only when [text] is non-null and non-blank. + */ + @RequiresEdt + fun setDescription(text: String?) { + descriptionText.text = text ?: "" + descriptionText.isVisible = !text.isNullOrBlank() + } + + // ---- public slot API ---- + + /** + * Optional panel rendered above the header row (e.g. summary + nav in + * [ai.kilocode.client.session.views.question.QuestionView]). When set, + * it is inserted as the first child of the column; calling with `null` + * removes a previously set component. + */ + @RequiresEdt + fun setTopPanel(top: JComponent?) { + this.top = top + rebuildCol() + } + + /** + * Optional icon rendered at the left edge of the header row. + * Pass `null` to remove the icon while keeping header text alignment stable. + */ + @RequiresEdt + fun setHeaderIcon(icon: Icon?, tooltip: String? = null) { + this.icon.icon = icon + this.icon.toolTipText = tooltip + this.icon.isVisible = icon != null + this.icon.revalidate() + this.icon.repaint() + } + + /** + * Replace the view-specific content slot that comes after the header/description. + * Pass `null` to remove the current content. + */ + @RequiresEdt + fun setContent(content: JComponent?) { + this.content = content + rebuildCol() + } + + /** + * Configure the action buttons shown in the card's right-aligned footer. + * + * All buttons are created fresh; stable button references across calls can be + * maintained by the caller through [setActionEnabled] using the [Action.id]. + * Pass an empty list to remove the footer entirely. + */ + @RequiresEdt + fun setActions(actions: List) { + actionButtons.clear() + actionFooter = if (actions.isEmpty()) { + null + } else { + val row = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.X_AXIS) + alignmentX = Component.LEFT_ALIGNMENT + } + for ((idx, action) in actions.withIndex()) { + if (idx > 0) row.add(Box.createHorizontalStrut(UiStyle.Gap.sm())) + val btn = makeButton(action.text, action.primary).apply { + isEnabled = action.enabled + addActionListener { action.handler() } + } + actionButtons[action.id] = btn + row.add(btn) + } + val footer = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = Component.LEFT_ALIGNMENT + } + footer.add(row, BorderLayout.EAST) + footer + } + rebuildCol() + } + + /** + * Enable or disable a specific action button identified by [id]. + * No-ops if the id is not found (e.g. before [setActions] is called). + */ + @RequiresEdt + fun setActionEnabled(id: String, enabled: Boolean) { + actionButtons[id]?.isEnabled = enabled + } + + // ---- SessionEditorStyleTarget ---- + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + for ((area, bold) in tracked) applyFont(area, bold) + } + + // ---- contentColor override ---- + + override fun contentColor(): Color = SessionUiStyle.View.surface() + + override fun outlineColor(): Color = SessionUiStyle.View.line() + + // ---- internal test helpers ---- + + /** Returns the font currently applied to the header text area. For tests only. */ + internal fun headerFont() = headerText.font + + /** Returns the font currently applied to the description text area. For tests only. */ + internal fun descriptionFont() = descriptionText.font + + /** Returns all action buttons as generic JButton, keyed by their action id. For tests only. */ + internal fun actionButtonsForTest(): Map = actionButtons.toMap() + + // ---- private helpers ---- + + private fun rebuildCol() { + col.removeAll() + top?.let { col.add(it) } + col.add(header) + col.add(descriptionText) + content?.let { + col.add(gap()) + col.add(it) + } + actionFooter?.let { + col.add(gap()) + col.add(it) + } + col.revalidate() + col.repaint() + } + + private fun gap(): Component = Box.createVerticalStrut(UiStyle.Gap.lg()).apply { + setAlignmentX(Component.LEFT_ALIGNMENT) + } + + private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { + val area = object : JBTextArea(value) { + override fun getPreferredSize() = withWidth(super.getPreferredSize().height) + + override fun getMaximumSize(): Dimension { + val size = preferredSize + return Dimension(Int.MAX_VALUE, size.height) + } + + private fun withWidth(fallback: Int): Dimension { + val w = availableWidth() + if (w <= 0) return Dimension(super.getPreferredSize().width, fallback) + val old = size + setSize(w, Int.MAX_VALUE) + val ps = super.getPreferredSize() + setSize(old) + return Dimension(w, ps.height) + } + + private fun availableWidth(): Int { + var node = parent + while (node != null) { + if (node.width > 0) { + val ins = node.insets + return (node.width - ins.left - ins.right).coerceAtLeast(0) + } + node = node.parent + } + return width + } + }.apply { + isEditable = false + isOpaque = false + isFocusable = false + caret.isVisible = false + caret.isSelectionVisible = false + lineWrap = true + wrapStyleWord = true + foreground = color + border = JBUI.Borders.empty() + alignmentX = Component.LEFT_ALIGNMENT + } + tracked.add(area to bold) + applyFont(area, bold) + return area + } + + private fun applyFont(area: JBTextArea, bold: Boolean) { + val font = if (bold) style.headerFont else style.hintFont + if (area.font != font) area.font = font + } + + private fun makeButton(text: String, primary: Boolean): JButton { + val btn = object : JButton(text) { + init { + if (primary) putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + syncBackground() + } + + override fun updateUI() { + super.updateUI() + syncBackground() + } + + private fun syncBackground() { + background = SessionUiStyle.View.surface() + } + } + return btn + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt similarity index 91% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 31db00f4ecd..0dcfb05e209 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.views +package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Generic @@ -36,8 +36,8 @@ class GenericView(content: Generic) : PartView() { fun labelText(): String = label.text override fun applyStyle(style: SessionEditorStyle) { - if (label.font == style.smallUiFont) return - label.font = style.smallUiFont + if (label.font == style.smallFont) return + label.font = style.smallFont revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt similarity index 77% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 1d32c764b7d..9cfacf9b41c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.views +package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -10,7 +10,7 @@ import javax.swing.JPanel * * Each subclass wraps one [Content] subtype and knows how to display * and update it. Subclasses extend [JPanel] so they can be added directly - * to [MessageView] without an extra component wrapper. + * to [ai.kilocode.client.session.views.MessageView] without an extra component wrapper. * * All methods must be called on the EDT. */ @@ -27,8 +27,8 @@ abstract class PartView : JPanel(), SessionEditorStyleTarget { /** * Append a streaming delta to the existing content. - * Only meaningful for text-bearing renderers ([TextView], [ReasoningView]); - * others ignore deltas by default. + * Only meaningful for text-bearing renderers ([ai.kilocode.client.session.views.TextView], + * [ai.kilocode.client.session.views.ReasoningView]); others ignore deltas by default. */ open fun appendDelta(delta: String) {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt new file mode 100644 index 00000000000..fd9c200b8e9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.session.views.permission + +import ai.kilocode.client.session.model.PermissionFileDiff +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.FlowLayout + +/** + * Renders a single [PermissionFileDiff] inside a permission card as a compact diff-stat badge. + * Patch content and file path are intentionally not displayed here; the permission target row + * already shows the path. + */ +class PermissionDiffView( + private val diff: PermissionFileDiff, +) : BorderLayoutPanel(), SessionEditorStyleTarget { + + private val badge = DiffStatBadge(diff.additions, diff.deletions) + + init { + isOpaque = false + + val row = buildRow() + addToCenter(row) + } + + override fun applyStyle(style: SessionEditorStyle) { + // Badge colors are theme-derived and update through Swing repainting. + } + + private fun buildRow() = JBUI.Panels.simplePanel().apply { + isOpaque = false + border = JBUI.Borders.empty() + + val inner = object : javax.swing.JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)) { + init { isOpaque = false } + } + inner.add(badge) + addToCenter(inner) + } + + // Test helpers + internal fun badgeForTest() = badge +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt new file mode 100644 index 00000000000..886cf788e4e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -0,0 +1,257 @@ +package ai.kilocode.client.session.views.permission + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionFileDiff +import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.views.base.BaseQuestionView +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.ui.style.SessionUiStyle.View.CARD_LAYOUT_GAP +import ai.kilocode.client.ui.UiStyle +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.PermissionReplyDto +import com.intellij.icons.AllIcons +import com.intellij.ui.ColorUtil +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.FlowLayout +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.text.html.StyleSheet + +/** + * Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel] + * at the end of the transcript when the session is in + * [ai.kilocode.client.session.model.SessionState.AwaitingPermission]. + * + * Shows a compact row with action label and target as an inline code fragment, plus diff badges. + */ +class PermissionView( + private val reply: (String, PermissionReplyDto) -> Unit, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { + override val sessionViewKind = SessionView.Kind.Default + + private var requestId: String? = null + private var style = SessionEditorStyle.current() + + private val card = BaseQuestionView() + + private val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + alignmentX = Component.LEFT_ALIGNMENT + } + + // Track target panes for style updates + private val panes = mutableListOf() + private val diffViews = mutableListOf() + + private val ID_DENY = "deny" + private val ID_RUN = "run" + + init { + isOpaque = false + isVisible = false + + card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title")) + card.setContent(body) + card.setActions(listOf( + BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") }, + BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.run"), primary = true) { decide("once") }, + )) + addToCenter(card) + } + + /** Populate the view for [permission] and make it visible. */ + fun show(permission: Permission) { + requestId = permission.id + + card.setHeader(KiloBundle.message("session.permission.title")) + + body.removeAll() + panes.clear() + diffViews.clear() + + val tool = permission.name + val cmd = permission.meta.command + + val action = toolLabel(tool) + val target = cmd ?: resolveTarget(permission) + addDetailRow(action, target, permission.meta.fileDiffs) + addStateMessage(permission) + + val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED + card.setActionEnabled(ID_RUN, !responding) + card.setActionEnabled(ID_DENY, !responding) + + isVisible = true + refresh() + } + + /** Hide this view and clear the active request id. */ + fun hideView() { + requestId = null + body.removeAll() + panes.clear() + diffViews.clear() + isVisible = false + refresh() + } + + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + card.applyStyle(style) + for (pane in panes) { + applyTargetPane(pane) + } + for (dv in diffViews) { + dv.applyStyle(style) + } + } + + /** Adds a three-column permission detail row: tool, target, and changes. */ + private fun addDetailRow(action: String, target: String?, diffs: List) { + val row = JPanel(BorderLayout(CARD_LAYOUT_GAP, 0)).apply { + isOpaque = false + alignmentX = Component.LEFT_ALIGNMENT + } + + val actionLbl = JBLabel(action).apply { + font = UiStyle.Fonts.bold() + } + row.add(actionLbl.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST) + + if (!target.isNullOrBlank()) { + val pane = targetPane(target) + panes.add(pane) + row.add(pane.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER) + } + + if (diffs.isNotEmpty()) { + val changes = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + } + for (diff in diffs) { + val dv = PermissionDiffView(diff) + diffViews.add(dv) + changes.add(dv) + } + row.add(changes.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST) + } + + body.add(row) + } + + private fun targetPane(text: String) = JBHtmlPane( + JBHtmlPaneStyleConfiguration {}, + JBHtmlPaneConfiguration { + customStyleSheetProvider { targetSheet() } + }, + ).apply { + isEditable = false + isOpaque = true + this.text = "
${XmlStringUtil.escapeString(text)}
" + applyTargetPane(this) + } + + private fun applyTargetPane(pane: JBHtmlPane) { + pane.font = style.transcriptFont + pane.foreground = style.editorForeground + pane.background = SessionUiStyle.View.headerHover() + pane.reloadCssStylesheets() + } + + private fun targetSheet(): StyleSheet { + val sheet = StyleSheet() + val font = style.transcriptFont + val fg = ColorUtil.toHtmlColor(style.editorForeground) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val family = font.name.replace("\\", "\\\\").replace("'", "\\'") + sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }") + sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }") + return sheet + } + + private fun resolveTarget(permission: Permission): String? { + val path = permission.meta.filePath + if (!path.isNullOrBlank()) return path + + val filtered = permission.patterns.filter { it != "*" } + return when { + filtered.size == 1 -> filtered[0] + filtered.size > 1 -> filtered.joinToString(", ") + else -> null + } + } + + private fun addStateMessage(permission: Permission) { + val msg = when (permission.state) { + PermissionRequestState.ERROR -> + permission.message ?: KiloBundle.message("session.permission.error") + PermissionRequestState.RESPONDING -> + KiloBundle.message("session.permission.responding") + else -> null + } ?: return + + val label = JBLabel(msg).apply { + border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + alignmentX = Component.LEFT_ALIGNMENT + } + body.add(label) + } + + private fun toolLabel(tool: String): String = when (tool) { + "read" -> KiloBundle.message("session.permission.tool.read") + "edit" -> KiloBundle.message("session.permission.tool.edit") + "write" -> KiloBundle.message("session.permission.tool.write") + "patch" -> KiloBundle.message("session.permission.tool.patch") + "multiedit" -> KiloBundle.message("session.permission.tool.multiedit") + "glob" -> KiloBundle.message("session.permission.tool.glob") + "grep" -> KiloBundle.message("session.permission.tool.grep") + "list" -> KiloBundle.message("session.permission.tool.list") + "bash" -> KiloBundle.message("session.permission.tool.bash") + "external_directory" -> KiloBundle.message("session.permission.tool.external_directory") + "webfetch" -> KiloBundle.message("session.permission.tool.webfetch") + "websearch" -> KiloBundle.message("session.permission.tool.websearch") + "codesearch" -> KiloBundle.message("session.permission.tool.codesearch") + "todoread" -> KiloBundle.message("session.permission.tool.todoread") + "todowrite" -> KiloBundle.message("session.permission.tool.todowrite") + "task" -> KiloBundle.message("session.permission.tool.task") + "skill" -> KiloBundle.message("session.permission.tool.skill") + "lsp" -> KiloBundle.message("session.permission.tool.lsp") + else -> tool + } + + private fun decide(value: String) { + val id = requestId ?: return + card.setActionEnabled(ID_RUN, false) + card.setActionEnabled(ID_DENY, false) + reply(id, PermissionReplyDto(reply = value)) + } + + private fun refresh() { + revalidate() + repaint() + parent?.revalidate() + parent?.repaint() + } + + // Test helpers + internal fun runButtonForTest() = card.actionButtonsForTest()[ID_RUN]!! + internal fun denyButtonForTest() = card.actionButtonsForTest()[ID_DENY]!! + internal fun codeLabelsForTest() = panes.toList() + internal fun diffViewsForTest() = diffViews.toList() + internal fun headerFontForTest() = card.headerFont() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index db1994ef8dd..e33303ede14 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -5,7 +5,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.PartView +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.ToolView import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons @@ -108,7 +108,9 @@ class QuestionResultView(tool: Tool) : PartView() { override fun applyStyle(style: SessionEditorStyle) { this.style = style - val label = setFont(title, style.boldEditorFont) || setFont(sub, style.smallEditorFont) + val t = setFont(title, style.boldFont) + val s = setFont(sub, style.smallFont) + val label = t || s val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } if (!label && !body) return refresh() @@ -137,6 +139,9 @@ class QuestionResultView(tool: Tool) : PartView() { fun bodyFonts(): List = texts.map { it.first.font } + fun titleFont(): Font = title.font + fun subFont(): Font = sub.font + override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})" companion object { @@ -268,7 +273,7 @@ class QuestionResultView(tool: Tool) : PartView() { } private fun setFont(area: JBTextArea, bold: Boolean): Boolean { - val font = if (bold) style.boldEditorFont else style.transcriptFont + val font = if (bold) style.boldFont else style.regularFont if (area.font == font) return false area.font = font return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index bae1be3e757..2613300e2df 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -5,14 +5,15 @@ import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.views.base.BaseQuestionView 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.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.icons.AllIcons -import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel @@ -20,21 +21,27 @@ import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel +import javax.swing.ScrollPaneConstants import java.awt.BorderLayout import java.awt.Color import java.awt.Component import java.awt.Dimension +import java.awt.GridBagLayout +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.AbstractButton import javax.swing.Box import javax.swing.BoxLayout import javax.swing.ButtonGroup -import javax.swing.JButton import javax.swing.JPanel +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener /** Question tool form rendered inside the session transcript. */ class QuestionView( + private val project: Project, private val reply: (String, QuestionReplyDto) -> Unit, private val reject: (String) -> Unit, private val scroll: () -> Unit = {}, @@ -45,27 +52,18 @@ class QuestionView( private var question: Question? = null private var idx = 0 private var selections = emptyList>() + // Per-question custom text state — survives navigation. + private var customTexts = emptyList() + // Per-question: whether the custom row is currently selected/open. + private var customOpen = emptyList() private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + // The custom editor for the currently shown question; null when not shown. + private var customEditor: SessionEditorTextField? = null + private var customFocus: FocusAdapter? = null + + private val card = BaseQuestionView() - private val card = object : BorderLayoutPanel() { - override fun updateUI() { - super.updateUI() - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } - } - private val root = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) - border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) - } - private val header = JPanel(BorderLayout()).apply { - isOpaque = false - border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) - alignmentX = Component.LEFT_ALIGNMENT - } private val summary = JBLabel() private val nav = JPanel().apply { isOpaque = false @@ -85,23 +83,21 @@ class QuestionView( toolTipText = KiloBundle.message("session.question.next") addActionListener { goForward() } } + private val topPanel = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + alignmentX = Component.LEFT_ALIGNMENT + } private val body = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) alignmentX = Component.LEFT_ALIGNMENT } - private val footer = JPanel(BorderLayout()).apply { - isOpaque = false - border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) - alignmentX = Component.LEFT_ALIGNMENT - } - private val dismiss = JButton(KiloBundle.message("session.question.dismiss")).apply { - addActionListener { doReject() } - } - private val right = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.X_AXIS) - } + + // Stable action ids for setActionEnabled calls + private val ID_DISMISS = "dismiss" + private val ID_BACK = "back" + private val ID_MAIN = "main" // next / review / submit init { isOpaque = false @@ -109,14 +105,11 @@ class QuestionView( nav.add(back) nav.add(fwd) - header.add(summary, BorderLayout.WEST) - header.add(nav, BorderLayout.EAST) - footer.add(dismiss, BorderLayout.WEST) - footer.add(right, BorderLayout.EAST) - root.add(header) - root.add(body) - root.add(footer) - card.add(root, BorderLayout.CENTER) + topPanel.add(summary, BorderLayout.WEST) + topPanel.add(nav, BorderLayout.EAST) + + card.setTopPanel(topPanel) + card.setContent(body) add(card, BorderLayout.CENTER) } @@ -129,6 +122,8 @@ class QuestionView( question = q idx = 0 selections = List(q.items.size) { mutableSetOf() } + customTexts = List(q.items.size) { "" } + customOpen = List(q.items.size) { false } isVisible = true syncPage() } @@ -138,15 +133,25 @@ class QuestionView( question = null idx = 0 selections = emptyList() + customTexts = emptyList() + customOpen = emptyList() + customEditor = null + customFocus = null texts.clear() body.removeAll() - right.removeAll() + card.setActions(emptyList()) isVisible = false refresh() } override fun applyStyle(style: SessionEditorStyle) { this.style = style + card.applyStyle(style) + customEditor?.let { ed -> + ed.font = style.transcriptFont + ed.getEditor(false)?.let(style::applyToEditor) + ed.background = style.editorScheme.defaultBackground + } val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } if (!changed) return refresh() @@ -155,8 +160,20 @@ class QuestionView( private fun syncPage() { val q = question ?: return texts.clear() + customEditor = null + customFocus = null body.removeAll() - if (review(q)) addReview(q) else addContent(q.items[idx], selections[idx]) + if (review(q)) { + card.setHeader(KiloBundle.message("session.question.review.title")) + addReview(q) + } else { + val item = q.items[idx] + val hint = KiloBundle.message( + if (item.multiple) "session.question.hint.multi" else "session.question.hint.single" + ) + card.setHeader(item.question, hint) + addContent(item, selections[idx]) + } syncHeader(q) syncFooter(q) syncControls(q) @@ -172,80 +189,86 @@ class QuestionView( } private fun syncFooter(q: Question) { - right.removeAll() - if (review(q)) { - val back = JButton(KiloBundle.message("session.question.back")).apply { - addActionListener { goBack() } - } - val submit = JButton(KiloBundle.message("session.question.submit")).apply { - putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) - addActionListener { doReply() } - } - right.add(back) - right.add(Box.createHorizontalStrut(JBUI.scale(UiStyle.Gap.sm()))) - right.add(submit) - return - } + val actions = mutableListOf() + actions.add(BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.question.dismiss"), primary = false) { doReject() }) - val label = when { - direct(q) -> KiloBundle.message("session.question.submit") - lastItem(q) -> KiloBundle.message("session.question.review") - else -> KiloBundle.message("session.question.next") - } - val button = JButton(label).apply { - putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, direct(q) || lastItem(q)) - addActionListener { + if (review(q)) { + actions.add(BaseQuestionView.Action(ID_BACK, KiloBundle.message("session.question.back"), primary = false) { goBack() }) + actions.add(BaseQuestionView.Action(ID_MAIN, KiloBundle.message("session.question.submit"), primary = true) { doReply() }) + } else { + val label = when { + direct(q) -> KiloBundle.message("session.question.submit") + lastItem(q) -> KiloBundle.message("session.question.review") + else -> KiloBundle.message("session.question.next") + } + val isPrimary = direct(q) || lastItem(q) + actions.add(BaseQuestionView.Action(ID_MAIN, label, isPrimary) { when { direct(q) -> doReply() lastItem(q) -> goReview() else -> goForward() } - } + }) } - right.add(button) + card.setActions(actions) } private fun syncControls(q: Question) { - val ready = selections.getOrNull(idx)?.isNotEmpty() == true + val ready = isReady(idx) back.isEnabled = idx > 0 fwd.isEnabled = idx < q.items.size && ready - for (node in right.components) { - if (node is JButton && node.text != KiloBundle.message("session.question.back")) { - node.isEnabled = review(q) || ready - } + card.setActionEnabled(ID_MAIN, review(q) || ready) + } + + /** + * Computes whether the question at [i] has an effective (non-blank) answer. + * For a question with custom=true and custom row selected, the custom text + * must be non-blank. For option-only answers the selection set must be non-empty. + */ + private fun isReady(i: Int): Boolean { + val open = customOpen.getOrElse(i) { false } + val txt = customTexts.getOrElse(i) { "" }.trim() + val sel = selections.getOrNull(i) + return if (open) txt.isNotEmpty() else sel?.isNotEmpty() == true + } + + /** + * Returns the effective answers for question at index [i] — what will be sent + * in the reply payload. Custom text is included when non-blank and the custom + * row is selected (single-select) or active (multi-select). + */ + private fun effectiveAnswers(i: Int): List { + val q = question ?: return emptyList() + val item = q.items.getOrNull(i) ?: return emptyList() + val txt = customTexts.getOrElse(i) { "" }.trim() + val open = customOpen.getOrElse(i) { false } + val sel = selections.getOrNull(i) ?: emptySet() + + return if (item.multiple) { + val result = sel.toMutableList() + if (open && txt.isNotEmpty() && txt !in result) result.add(txt) + result + } else { + // single-select: if custom is open, use custom text; otherwise use selection + if (open && txt.isNotEmpty()) listOf(txt) + else sel.toList() } } private fun addContent(item: QuestionItem, set: MutableSet) { - val title = text(item.question, UiStyle.Colors.fg(), true) - title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) - title.alignmentX = Component.LEFT_ALIGNMENT - body.add(title) - - val hint = text( - KiloBundle.message(if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"), - UiStyle.Colors.weak(), - ) - hint.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) - hint.alignmentX = Component.LEFT_ALIGNMENT - body.add(hint) - val opts = optionList(item, set) opts.alignmentX = Component.LEFT_ALIGNMENT body.add(opts) } private fun addReview(q: Question) { - val title = text(KiloBundle.message("session.question.review.title"), UiStyle.Colors.fg(), true) - title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) - title.alignmentX = Component.LEFT_ALIGNMENT - body.add(title) - for ((i, item) in q.items.withIndex()) { val row = reviewRow(item, i) row.alignmentX = Component.LEFT_ALIGNMENT body.add(row) } + // Remove bottom padding on the last review row to match the top gap. + (body.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty() } private fun reviewRow(item: QuestionItem, i: Int): JPanel { @@ -254,11 +277,12 @@ class QuestionView( layout = BoxLayout(this, BoxLayout.Y_AXIS) border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) } - val question = text(item.question, UiStyle.Colors.weak()) - question.alignmentX = Component.LEFT_ALIGNMENT - row.add(question) + val qText = text(item.question, UiStyle.Colors.weak()) + qText.alignmentX = Component.LEFT_ALIGNMENT + row.add(qText) - val joined = selections.getOrNull(i)?.joinToString(", ").orEmpty() + val answers = effectiveAnswers(i) + val joined = answers.joinToString(", ") val answer = text( joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") }, UiStyle.Colors.fg(), @@ -276,13 +300,245 @@ class QuestionView( } if (item.multiple) { for (opt in item.options) panel.add(checkboxRow(opt, set)) - return panel + } else { + val group = ButtonGroup() + for (opt in item.options) panel.add(radioRow(opt, set, group)) + } + + if (item.custom) { + panel.add(customRow(item, set)) + } else { + // Remove bottom padding on the last option so the gap before the action + // footer matches the gap above the options (both use Gap.lg). + (panel.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty() } - val group = ButtonGroup() - for (opt in item.options) panel.add(radioRow(opt, set, group)) return panel } + private fun customRow(item: QuestionItem, set: MutableSet): JPanel { + val open = customOpen.getOrElse(idx) { false } + val existing = customTexts.getOrElse(idx) { "" }.trim() + val showEditor = open || existing.isNotEmpty() + val row = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + // No bottom padding — it's the last row + border = JBUI.Borders.empty() + } + + val toggle: AbstractButton = if (item.multiple) { + JBCheckBox().apply { + actionCommand = "" + isSelected = open + isOpaque = false + } + } else { + JBRadioButton().apply { + actionCommand = "" + isSelected = open + isOpaque = false + } + } + + val toggleListener = { + val wasOpen = customOpen.getOrElse(idx) { false } + if (!wasOpen) { + // Opening custom row + if (!item.multiple) { + // Single-select: clear option selection + set.clear() + } + customOpen = customOpen.toMutableList().also { it[idx] = true } + } else { + // Closing custom row + customOpen = customOpen.toMutableList().also { it[idx] = false } + } + refreshCustomRow() + } + + if (item.multiple) { + (toggle as JBCheckBox).addActionListener { toggleListener() } + } else { + (toggle as JBRadioButton).addActionListener { + // When the custom radio is selected, deselect any option radio + set.clear() + customOpen = customOpen.toMutableList().also { it[idx] = true } + refreshCustomRow() + } + } + + val press = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (toggle.isEnabled) toggle.doClick() + } + } + + val icon = JPanel(GridBagLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) + add(toggle) + addMouseListener(press) + } + + val col = JPanel().apply { + isOpaque = false + layout = GridBagLayout() + addMouseListener(press) + } + + val label = text(KiloBundle.message("session.question.custom.label"), UiStyle.Colors.fg(), true) + label.alignmentX = Component.LEFT_ALIGNMENT + label.addMouseListener(press) + col.add(label) + + val header = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + toolTipText = null + alignmentX = Component.LEFT_ALIGNMENT + } + header.addMouseListener(press) + header.add(icon, BorderLayout.WEST) + header.add(col, BorderLayout.CENTER) + row.add(header) + + if (showEditor) { + val ed = buildCustomEditor() + customEditor = ed + val focus = object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectCustom(item, set) + } + customFocus = focus + ed.addFocusListener(focus) + ed.addSettingsProvider { ex -> + ex.contentComponent.addFocusListener(focus) + ex.component.addFocusListener(focus) + } + val edWrapper = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(0, UiStyle.Gap.lg() + JBUI.scale(20), UiStyle.Gap.lg(), 0) + alignmentX = Component.LEFT_ALIGNMENT + add(ed, BorderLayout.CENTER) + } + row.add(edWrapper) + } + + return row + } + + internal fun testFocusCustomEditor() { + val ed = customEditor ?: return + val focus = customFocus ?: return + focus.focusGained(FocusEvent(ed, FocusEvent.FOCUS_GAINED)) + } + + private fun selectCustom(item: QuestionItem, set: MutableSet) { + if (customOpen.getOrElse(idx) { false }) return + if (!item.multiple) set.clear() + customOpen = customOpen.toMutableList().also { it[idx] = true } + refreshCustomRow() + } + + /** + * Builds and wires a custom-answer [SessionEditorTextField]. + * + * The component is created on the EDT (as required for all Swing components). + * [SessionEditorTextField] extends [com.intellij.ui.EditorTextField] which + * lazily initialises its IntelliJ editor via [com.intellij.openapi.editor.EditorThreading] + * the first time the component becomes visible, satisfying the platform's + * read-context requirement without any additional wrapping here. + */ + private fun buildCustomEditor(): SessionEditorTextField { + val ed = SessionEditorTextField(project) + ed.border = JBUI.Borders.empty() + ed.setFontInheritedFromLAF(false) + ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) + ed.setShowPlaceholderWhenFocused(true) + ed.setOneLineMode(false) + ed.addSettingsProvider { ex -> + style.applyToEditor(ex) + ex.setBorder(JBUI.Borders.empty()) + ex.scrollPane.border = JBUI.Borders.empty() + ex.scrollPane.viewportBorder = JBUI.Borders.empty() + ex.backgroundColor = style.editorScheme.defaultBackground + ex.scrollPane.background = style.editorScheme.defaultBackground + ex.scrollPane.viewport.background = style.editorScheme.defaultBackground + ex.settings.isUseSoftWraps = true + ex.settings.isAdditionalPageAtBottom = false + ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } + ed.font = style.transcriptFont + ed.background = style.editorScheme.defaultBackground + + // Pre-fill with saved text. This call also forces lazy document creation so + // that addDocumentListener can install on a non-null document immediately. + val saved = customTexts.getOrElse(idx) { "" } + ed.text = saved + + // Sync preferred height to line count; update stored text on edits. + // EditorTextField.addDocumentListener is the preferred (non-deprecated) API. + // The document was already created above (ed.text = saved ensures getDocument() + // was called), so installDocumentListener succeeds. + ed.addDocumentListener(object : DocumentListener { + override fun documentChanged(e: DocumentEvent) { + val txt = ed.text + customTexts = customTexts.toMutableList().also { it[idx] = txt } + syncEditorHeight(ed) + question?.let(::syncControls) + refresh() + scroll() + } + }) + + syncEditorHeight(ed) + return ed + } + + private fun syncEditorHeight(ed: SessionEditorTextField) { + val editor = ed.getEditor(false) + val estimated = estimatedLines(ed) + val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated) + val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height + val height = line * lines.coerceAtLeast(1) + JBUI.scale(16) + ed.preferredSize = Dimension(0, height) + ed.minimumSize = Dimension(0, height) + } + + private fun estimatedLines(ed: SessionEditorTextField): Int { + val width = space(ed) + if (width <= 0) return (ed.text.count { it == '\n' } + 1).coerceAtLeast(1) + val metrics = ed.getFontMetrics(ed.font) + val columns = (width / metrics.charWidth('m').coerceAtLeast(1)).coerceAtLeast(1) + return ed.text.lineSequence().sumOf { line -> + ((line.length + columns - 1) / columns).coerceAtLeast(1) + }.coerceAtLeast(1) + } + + private fun space(component: Component): Int { + if (component.width > 0) return component.width + var node = component.parent + while (node != null) { + if (node.width > 0) { + val ins = node.insets + return (node.width - ins.left - ins.right).coerceAtLeast(0) + } + node = node.parent + } + return 0 + } + + /** Re-syncs the current page after the custom row toggle changes. */ + private fun refreshCustomRow() { + val q = question ?: return + syncPage() + // Request focus on the editor when opening + if (customOpen.getOrElse(idx) { false }) { + customEditor?.requestFocusInWindow() + } + syncControls(q) + scroll() + } + private fun radioRow(opt: QuestionOption, set: MutableSet, group: ButtonGroup): JPanel { val radio = JBRadioButton().apply { actionCommand = opt.label @@ -293,7 +549,13 @@ class QuestionView( radio.addActionListener { set.clear() set.add(opt.label) - refreshSelection() + // Selecting a normal option closes the custom row + customOpen = customOpen.toMutableList().also { it[idx] = false } + if (customEditor == null) { + refreshSelection() + return@addActionListener + } + refreshCustomRow() } return optionRow(radio, opt) } @@ -323,15 +585,16 @@ class QuestionView( if (toggle.isEnabled) toggle.doClick() } } - val icon = JPanel(BorderLayout()).apply { + val center = opt.description.isBlank() + val icon = JPanel(if (center) GridBagLayout() else BorderLayout()).apply { isOpaque = false border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) - add(toggle, BorderLayout.NORTH) + if (center) add(toggle) else add(toggle, BorderLayout.NORTH) addMouseListener(press) } val col = JPanel().apply { isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) + layout = if (center) GridBagLayout() else BoxLayout(this, BoxLayout.Y_AXIS) addMouseListener(press) } val label = text(opt.label, UiStyle.Colors.fg(), true) @@ -415,12 +678,12 @@ class QuestionView( private fun goForward() { val q = question ?: return - if (idx >= q.items.size || selections.getOrNull(idx)?.isEmpty() != false) return - val review = idx == q.items.size - 1 && !direct(q) - if (review) { + if (idx >= q.items.size || !isReady(idx)) return + val toReview = idx == q.items.size - 1 && !direct(q) + if (toReview) { goReview() } - if (!review) { + if (!toReview) { idx++ syncPage() scroll() @@ -429,7 +692,7 @@ class QuestionView( private fun goReview() { val q = question ?: return - if (idx == q.items.size - 1 && selections[idx].isNotEmpty()) { + if (idx == q.items.size - 1 && isReady(idx)) { idx = q.items.size syncPage() scroll() @@ -444,8 +707,9 @@ class QuestionView( private fun doReply() { val id = request ?: return - if (selections.any { it.isEmpty() }) return - reply(id, QuestionReplyDto(selections.map { it.toList() })) + if ((question?.items?.indices ?: return).any { !isReady(it) }) return + val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) } + reply(id, QuestionReplyDto(answers)) hideView() } @@ -456,7 +720,7 @@ class QuestionView( } private fun setFont(area: JBTextArea, bold: Boolean): Boolean { - val font = if (bold) style.boldEditorFont else style.transcriptFont + val font = if (bold) style.boldFont else style.regularFont if (area.font == font) return false area.font = font return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt new file mode 100644 index 00000000000..586be860e38 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -0,0 +1,64 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.DataManager +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.options.ex.Settings +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Root settings entry under Settings -> Tools -> Kilo Code. + * + * Displays a brief description and a link to the User Profile child page. + * Child configurables are registered in XML (`kilo.jetbrains.frontend.xml`) as + * `applicationConfigurable` entries with the appropriate `parentId` — that is the + * single source of truth for the settings hierarchy. This class does NOT implement + * [com.intellij.openapi.options.SearchableConfigurable.Parent] to avoid creating a + * second `UserProfileConfigurable` instance alongside the one registered in XML. + * + * The link uses [UserProfileConfigurable.ID] to navigate via [Settings.find]/[Settings.select]. + */ +class KiloSettingsConfigurable : SearchableConfigurable { + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.kilo.displayName") + + override fun createComponent(): JComponent { + val panel = JPanel() + panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + panel.border = JBUI.Borders.empty(8, 0, 0, 0) + + val desc = JBLabel(KiloBundle.message("settings.kilo.description")) + desc.border = JBUI.Borders.emptyBottom(12) + panel.add(desc) + + val link = ActionLink(KiloBundle.message("settings.profile.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, UserProfileConfigurable.ID) + } + link.border = JBUI.Borders.emptyBottom(4) + panel.add(link) + + return panel + } + + override fun isModified(): Boolean = false + + override fun apply() = Unit + + internal fun open(settings: Settings, id: String = UserProfileConfigurable.ID) { + settings.find(id)?.let { settings.select(it) } + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt new file mode 100644 index 00000000000..2c7c77a383c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt @@ -0,0 +1,8 @@ +package ai.kilocode.client.settings.profile + +import java.text.DecimalFormat + +private val FMT = DecimalFormat("\$#,##0.00") + +/** Format a USD balance value for display (e.g. `$1,234.56`). */ +internal fun formatBalance(value: Double): String = FMT.format(value) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt new file mode 100644 index 00000000000..aa74f1c5d31 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt @@ -0,0 +1,265 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ProfileDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.RelativeFont +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.KeyboardFocusManager +import java.awt.event.FocusEvent +import java.awt.event.FocusListener +import javax.swing.DefaultComboBoxModel +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Retained logged-in UI. Labels, combo box, and buttons are built once and + * mutated in [update] — no component rebuilding. + */ +internal class LoggedInProfileUi( + private val dashboard: () -> Unit, + private val logout: () -> Unit, + private val organization: (String?) -> Unit, + private val refresh: () -> Unit, +) : BorderLayoutPanel() { + + companion object { + private val LOG = KiloLog.create(LoggedInProfileUi::class.java) + } + + private val nameLabel = JBLabel().also { RelativeFont.BOLD.install(it) } + private val emailLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + setCopyable(true) + } + + private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply { + foreground = UiStyle.Colors.weak() + } + private val valueLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + font = UiStyle.Fonts.display() + } + private val refreshBtn = JButton(KiloBundle.message("profile.action.refresh"), AllIcons.Actions.Refresh) + .also { + it.isOpaque = false + it.isContentAreaFilled = false + it.addActionListener { + if (refreshing) return@addActionListener + setRefreshing(true) + refresh() + } + } + + private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { + name = "kilo.profile.balanceCard" + addToTop(titleLabel) + addToCenter(JPanel(GridBagLayout()).apply { + isOpaque = false + add(valueLabel, GridBagConstraints().apply { + gridx = 0; gridy = 0; anchor = GridBagConstraints.CENTER + }) + add(refreshBtn, GridBagConstraints().apply { + gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER + insets = JBUI.insetsTop(UiStyle.Gap.pad()) + }) + }) + } + + private val comboModel = DefaultComboBoxModel() + val combo = ComboBox(comboModel) + + val dashboardBtn = JButton(KiloBundle.message("profile.action.dashboard")) + .also { it.addActionListener { dashboard() } } + val logoutBtn = JButton(KiloBundle.message("profile.action.logout")) + .also { it.addActionListener { logout() } } + + private val actionRow = JPanel(GridBagLayout()).apply { + add(dashboardBtn, GridBagConstraints().apply { + gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST + }) + add(logoutBtn, GridBagConstraints().apply { + gridx = 1; gridy = 0; anchor = GridBagConstraints.WEST + insets = JBUI.insetsLeft(UiStyle.Gap.md()) + }) + } + + private val rows: List = listOf(nameLabel, emailLabel, combo, balanceCard, actionRow) + + private val content = JPanel(GridBagLayout()).apply { + val gap = UiStyle.Gap.lg() + rows.forEachIndexed { i, comp -> + add(comp, GridBagConstraints().apply { + gridx = 0; gridy = i + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.WEST + insets = if (i == 0) JBUI.emptyInsets() else JBUI.insetsTop(gap) + }) + } + } + + private var applying = false + private var refreshing = false + // Stable identity cache: (orgId or null for personal) to display name. + // Reflects what is currently shown in the retained combo model. + private var comboKeys: List> = emptyList() + // The orgId that was current as of the last applied profile update. + private var currentOrgId: String? = null + + init { + combo.addFocusListener(object : FocusListener { + override fun focusGained(e: FocusEvent) = logFocus("gained", e) + override fun focusLost(e: FocusEvent) = logFocus("lost", e) + }) + combo.addActionListener { + if (applying) return@addActionListener // programmatic update — suppress RPC + val idx = combo.selectedIndex + if (idx < 0 || idx >= comboKeys.size) return@addActionListener + val orgId = comboKeys[idx].first + // currentOrgId reflects the last profile applied by applyOrganizations. + // applying=true during model/selection changes prevents re-entry here. + if (orgId == currentOrgId) return@addActionListener + organization(orgId) + } + addToTop(content) + } + + @RequiresEdt + fun preferredFocus(): JComponent = if (combo.isVisible) combo else dashboardBtn + + private fun logFocus(kind: String, e: FocusEvent) { + val edge = if (kind == "lost") "to" else "from" + val mode = if (e.isTemporary) "temporary" else "permanent" + val peer = e.oppositeComponent?.let { + "${it.javaClass.name} name=${it.name ?: "-"} showing=${it.isShowing} visible=${it.isVisible}" + } ?: "unknown" + val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner?.let { + "${it.javaClass.name} name=${it.name ?: "-"}" + } ?: "unknown" + LOG.info( + "org combo focus $kind [$mode] $edge=$peer owner=$owner " + + "popup=${combo.isPopupVisible} selected=${combo.selectedIndex} " + + "size=${comboModel.size} visible=${combo.isVisible} showing=${combo.isShowing}", + ) + } + + @RequiresEdt + fun update(profile: ProfileDto) { + val display = profile.name?.takeIf { it.isNotBlank() } ?: profile.email + if (nameLabel.text != display) nameLabel.text = display + + val showEmail = profile.name != null + if (emailLabel.isVisible != showEmail) emailLabel.isVisible = showEmail + if (showEmail && emailLabel.text != profile.email) emailLabel.text = profile.email + + val bal = profile.balance + var changed = false + if (bal != null) { + val balText = formatBalance(bal.balance) + if (valueLabel.text != balText) { + valueLabel.text = balText + changed = true + } + if (!balanceCard.isVisible) { + balanceCard.isVisible = true + changed = true + } + } else { + if (balanceCard.isVisible) { + balanceCard.isVisible = false + changed = true + } + } + + applyOrganizations(profile) + if (changed) syncLayout() + } + + @RequiresEdt + fun setRefreshing(refreshing: Boolean) { + if (this.refreshing == refreshing) return + this.refreshing = refreshing + val text = if (refreshing) KiloBundle.message("profile.action.refreshing") + else KiloBundle.message("profile.action.refresh") + if (refreshBtn.text != text) refreshBtn.text = text + syncLayout() + } + + @RequiresEdt + private fun syncLayout() { + balanceCard.revalidate() + content.revalidate() + revalidate() + repaint() + } + + @RequiresEdt + private fun applyOrganizations(profile: ProfileDto) { + val orgs = profile.organizations + val keys: List> = listOf(null to KiloBundle.message("profile.personalAccount")) + + orgs.map { it.id to it.name } + + val target = profile.currentOrgId + ?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) } + ?: 0 + + currentOrgId = profile.currentOrgId + + applying = true + try { + if (keys != comboKeys) { + comboKeys = keys + syncModel(keys) + } + if (combo.selectedIndex != target) combo.selectedIndex = target + } finally { + applying = false + } + + val show = orgs.isNotEmpty() + if (combo.isVisible != show) { + combo.isVisible = show + syncLayout() + } + } + + /** + * Reconcile [comboModel] with [keys] in place — never empties the model. + * + * - Trim excess elements from the tail (avoids transient empty state). + * - Update or append each position by name. + * This keeps the model always non-empty during changes, preserving popup/focus state. + */ + @RequiresEdt + private fun syncModel(keys: List>) { + if (comboModel.size == 0) { + keys.forEach { comboModel.addElement(it.second) } + return + } + // Remove excess from the end first so indices stay stable during updates below. + while (comboModel.size > keys.size) { + comboModel.removeElementAt(comboModel.size - 1) + } + keys.forEachIndexed { i, (_, name) -> + if (i >= comboModel.size) { + comboModel.addElement(name) + } else if (comboModel.getElementAt(i) != name) { + // Insert new name before the stale one, then remove stale — never leaves a gap. + comboModel.insertElementAt(name, i) + comboModel.removeElementAt(i + 1) + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt new file mode 100644 index 00000000000..f15a3b85d24 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -0,0 +1,395 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.AsyncProcessIcon +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.FlowLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.Point +import java.awt.datatransfer.StringSelection +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingConstants +import javax.swing.Timer + +internal enum class OutMode { CONNECTING, APP_ERROR, INITIATING, AUTH, LOGIN_ERROR, EMPTY } + +/** + * Retained logged-out UI. Internally uses a [CardLayout] to switch between + * connecting, error, device-auth, initiating, login-error, and not-logged-in states + * without rebuilding components on every state change. + */ +internal class LoggedOutProfileUi( + private val login: () -> Unit, + private val retry: () -> Unit, + private val cancel: () -> Unit, + private val browse: (String) -> Unit, +) : JPanel(BorderLayout()) { + + private val cards = JPanel(CardLayout()) + private val cardLayout = cards.layout as CardLayout + private var mode: OutMode? = null + + // -- retained buttons -- + val loginBtn = JButton(KiloBundle.message("profile.action.login")) + .also { it.addActionListener { login() } } + + private val retryBtnConnecting = JButton(KiloBundle.message("profile.action.retry")) + .also { it.addActionListener { retry() } } + + private val retryBtnError = JButton(KiloBundle.message("profile.action.retry")) + .also { it.addActionListener { retry() } } + + private val authRetryBtn = JButton(KiloBundle.message("profile.login.tryAgain")) + .also { it.addActionListener { login() } } + + private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel")) + .also { it.addActionListener { cancel() } } + + private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser")) + + private val copyUrlBtn = HoverIcon().apply { + icon = AllIcons.Actions.Copy + toolTipText = KiloBundle.message("profile.login.copyUrl") + } + + // -- retained auth card components -- + val urlField = JBTextField().apply { + isEditable = false + name = "kilo.login.url" + columns = 30 + // Select all on focus so clicking the field selects the whole URL + addFocusListener(object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectAll() + }) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) = selectAll() + }) + } + + val qrLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + name = "kilo.login.qr" + accessibleContext.accessibleName = KiloBundle.message("profile.login.qr") + accessibleContext.accessibleDescription = KiloBundle.message("profile.login.qr.description") + } + + private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { + name = "kilo.login.codePanel" + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val c = rawCode ?: return + copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@LoggedOutProfileUi) + } + }) + } + + private val codeLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + font = UiStyle.Fonts.large() + } + + private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + } + + private val initiatingIcon = AsyncProcessIcon("KiloInitiating").also { it.suspend() } + + private val waitIcon = AsyncProcessIcon("KiloLogin") + + private val waitLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + } + + private val errLabel = JBLabel().apply { + foreground = UiStyle.Colors.errorLabelForeground() + horizontalAlignment = SwingConstants.CENTER + } + + // -- step 2 label reference for visibility toggling -- + private var step2Label: SimpleColoredComponent? = null + + // -- countdown state -- + private var rawCode: String? = null + private var pendingStarted = 0L + private var pendingExpires = 900 + + // -- cached URL for listener/QR deduplication -- + private var lastPendingUrl: String? = null + + private val timer = Timer(1000) { syncTime() } + + init { + codePanel.add(codeLabel, BorderLayout.CENTER) + codePanel.add(codeHint, BorderLayout.SOUTH) + + cards.add(connectingCard(), OutMode.CONNECTING.name) + cards.add(appErrorCard(), OutMode.APP_ERROR.name) + cards.add(emptyCard(), OutMode.EMPTY.name) + cards.add(initiatingCard(), OutMode.INITIATING.name) + cards.add(authCard(), OutMode.AUTH.name) + cards.add(loginErrorCard(), OutMode.LOGIN_ERROR.name) + add(cards, BorderLayout.NORTH) + } + + // ---- card builders (called once in init) ---- + + private fun connectingCard(): JPanel { + val p = padded() + p.add(JBLabel(KiloBundle.message("profile.status.connecting")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + }, gbc(0)) + p.add(retryBtnConnecting, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + private fun appErrorCard(): JPanel { + val p = padded() + p.add(JBLabel(KiloBundle.message("profile.status.error")).apply { + foreground = UiStyle.Colors.errorLabelForeground() + horizontalAlignment = SwingConstants.CENTER + }, gbc(0)) + p.add(retryBtnError, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + private fun emptyCard(): JPanel { + val p = padded() + p.add(JBLabel(KiloBundle.message("profile.notLoggedIn")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + }, gbc(0)) + p.add(loginBtn, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + private fun initiatingCard(): JPanel { + val p = padded() + val row = JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(initiatingIcon) + add(JBLabel(KiloBundle.message("profile.login.starting")).apply { + foreground = UiStyle.Colors.weak() + }) + } + p.add(row, gbc(0).centered()) + return p + } + + private fun authCard(): JPanel { + val p = padded() + var row = 0 + + p.add(JBLabel(KiloBundle.message("profile.login.title")).apply { + font = UiStyle.Fonts.heading() + horizontalAlignment = SwingConstants.CENTER + }, gbc(row++)) + + p.add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")), + gbc(row++, UiStyle.Gap.md())) + + p.add(urlRow(), gbc(row++, UiStyle.Gap.sm())) + + p.add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered()) + + val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code")) + step2Label = s2 + p.add(s2, gbc(row++, UiStyle.Gap.md())) + + p.add(codePanel, gbc(row++, UiStyle.Gap.sm())) + + val waitRow = JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(waitIcon) + add(waitLabel) + } + p.add(waitRow, gbc(row++, UiStyle.Gap.xl())) + + p.add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered()) + + return p + } + + private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply { + append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) + append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + + private fun urlRow(): JPanel { + val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)) + row.add(urlField, BorderLayout.CENTER) + val btns = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(copyUrlBtn) + add(openBtn) + } + row.add(btns, BorderLayout.EAST) + return row + } + + private fun loginErrorCard(): JPanel { + val p = padded() + p.add(errLabel, gbc(0)) + p.add(authRetryBtn, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + // ---- update ---- + + @RequiresEdt + fun update(status: KiloAppStatusDto, login: LoginState) { + val target = resolveMode(status, login) + + if (target == OutMode.AUTH && login is LoginState.Pending) { + val auth = login.auth + val url = auth.verificationUrl + val code = auth.code + + rawCode = code + urlField.text = url + urlField.toolTipText = url + + // Wire listeners and generate QR only when URL changes (avoids re-wiring on every re-sync) + if (url != lastPendingUrl) { + lastPendingUrl = url + + openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) } + openBtn.addActionListener { browse(url) } + copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) } + copyUrlBtn.addActionListener { + copyToClipboard(url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn) + } + + // QR code — expensive; only regenerate when URL changes + try { + qrLabel.icon = QrCode.icon(url, JBUI.scale(160)) + } catch (_: Exception) { + qrLabel.icon = null + } + } + + // Code display + codePanel.isVisible = code != null + step2Label?.isVisible = code != null + if (code != null) { + codeLabel.text = spacedCode(code) + } + + // Countdown: only reset when entering auth for the first time for this pending + if (mode != OutMode.AUTH) { + pendingStarted = login.started + pendingExpires = auth.expiresIn + syncTime() + timer.restart() + } + } + + if (target == OutMode.LOGIN_ERROR && login is LoginState.Error) { + errLabel.text = login.message + } + + if (mode != target) { + if (mode == OutMode.AUTH) { + timer.stop() + waitIcon.suspend() + lastPendingUrl = null + } + if (mode == OutMode.INITIATING) initiatingIcon.suspend() + cardLayout.show(cards, target.name) + mode = target + if (target == OutMode.AUTH) { + waitIcon.resume() + } + if (target == OutMode.INITIATING) initiatingIcon.resume() + revalidate() + repaint() + } + } + + @RequiresEdt + fun preferredFocus(): JComponent = loginBtn + + /** Stop the timer and suspend all animated icons. Safe to call multiple times. */ + @RequiresEdt + fun dispose() { + timer.stop() + waitIcon.suspend() + initiatingIcon.suspend() + lastPendingUrl = null + } + + private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { + status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING -> OutMode.CONNECTING + status == KiloAppStatusDto.ERROR -> OutMode.APP_ERROR + login is LoginState.Initiating -> OutMode.INITIATING + login is LoginState.Pending -> OutMode.AUTH + login is LoginState.Error -> OutMode.LOGIN_ERROR + else -> OutMode.EMPTY + } + + @RequiresEdt + private fun syncTime() { + val elapsed = ((System.currentTimeMillis() - pendingStarted) / 1000).toInt() + val remain = (pendingExpires - elapsed).coerceAtLeast(0) + val min = remain / 60 + val sec = remain % 60 + waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}") + } + + // ---- helpers ---- + + private fun padded() = JPanel(GridBagLayout()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + } + + private fun gbc(y: Int, top: Int = 0) = GridBagConstraints().apply { + gridx = 0 + gridy = y + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + insets = JBUI.insetsTop(top) + } + + private fun GridBagConstraints.centered(): GridBagConstraints = apply { + fill = GridBagConstraints.NONE + anchor = GridBagConstraints.CENTER + } + + private fun spacedCode(code: String): String = code.map { it.toString() }.joinToString(" ") +} + +/** Copy [text] to the platform clipboard and show a brief confirmation balloon anchored to [anchor]. */ +private fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) { + CopyPasteManager.getInstance().setContents(StringSelection(text)) + if (anchor is javax.swing.JComponent) { + val point = RelativePoint(anchor, Point(anchor.width / 2, 0)) + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder(msg, null, null, null) + .createBalloon() + .show(point, Balloon.Position.above) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoginState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoginState.kt new file mode 100644 index 00000000000..6aaa1be1b6a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoginState.kt @@ -0,0 +1,10 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.rpc.dto.DeviceAuthDto + +internal sealed interface LoginState { + data object Idle : LoginState + data object Initiating : LoginState + data class Pending(val auth: DeviceAuthDto, val started: Long) : LoginState + data class Error(val message: String) : LoginState +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt new file mode 100644 index 00000000000..689f908e5e4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -0,0 +1,290 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileStatusDto +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import java.awt.CardLayout +import javax.swing.JComponent +import javax.swing.JPanel + +internal const val DASHBOARD_URL = "https://app.kilo.ai/profile" + +internal val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + +private enum class Card { LOGGED_OUT, LOGGED_IN } + +/** + * Retained top-level profile UI component. + * + * Builds [LoggedOutProfileUi] and [LoggedInProfileUi] once and switches between them + * using a [CardLayout] — no [removeAll] or panel rebuilds on state changes. + */ +internal class ProfileUi( + profile: ProfileDto?, + status: KiloAppStatusDto, + private val cs: CoroutineScope, + private val app: KiloAppService = service(), + private val browse: (String) -> Unit = { BrowserUtil.browse(it) }, +) : JPanel(BorderLayout()) { + + private val cards = JPanel(CardLayout()) + private val cardLayout = cards.layout as CardLayout + + private val out = LoggedOutProfileUi( + login = ::start, + retry = { app.retryAsync() }, + cancel = ::cancel, + browse = browse, + ) + private val account = LoggedInProfileUi( + dashboard = { browse(DASHBOARD_URL) }, + logout = ::logout, + organization = ::organization, + refresh = ::refreshProfile, + ) + + private var prof = profile + private var status = status + private var login: LoginState = LoginState.Idle + private var attempt = 0 + private var shown: Card? = null + + init { + cards.add(out, Card.LOGGED_OUT.name) + cards.add(account, Card.LOGGED_IN.name) + add(cards, BorderLayout.NORTH) + sync() + } + + @RequiresEdt + fun preferredFocus(): JComponent = when (targetCard()) { + Card.LOGGED_IN -> account.preferredFocus() + Card.LOGGED_OUT -> out.preferredFocus() + } + + /** + * Update from a full app state snapshot. + * + * A null profile is only treated as transient (keep the logged-in card without updating + * account content) when [KiloAppStateDto.progress]`.profile` is [ProfileStatusDto.PENDING], + * meaning a switch or initial load is still in flight. Any other null (no progress, + * NOT_LOGGED_IN, etc.) clears the profile and shows the logged-out card. + */ + @RequiresEdt + fun update(state: KiloAppStateDto) { + checkEdt() + this.status = state.status + val transient = state.profile == null && state.progress?.profile == ProfileStatusDto.PENDING + when { + state.profile != null -> { + prof = state.profile + login = LoginState.Idle + } + transient -> { /* keep existing prof and account UI untouched */ } + else -> prof = null + } + sync(skipAccount = transient) + } + + /** + * Convenience overload for callers that already hold separate profile/status values + * (login flow, direct tests). Null profile clears [prof] only when there is no existing + * profile; otherwise keeps the logged-in card visible without updating account content. + * Callers that pass null always provide a state fallback (`profile ?: state.profile`), + * so this branch is not reachable in production — it exists for transient-null tests. + */ + @RequiresEdt + fun update(profile: ProfileDto?, status: KiloAppStatusDto) { + checkEdt() + this.status = status + val transient = profile == null && prof != null + if (profile != null) { + prof = profile + login = LoginState.Idle + } else if (!transient) { + prof = null + } + sync(skipAccount = transient) + } + + @RequiresEdt + private fun sync(skipAccount: Boolean = false) { + checkEdt() + val target = targetCard() + if (target == Card.LOGGED_OUT) { + out.update(status, login) + } else if (!skipAccount) { + prof?.let { account.update(it) } + } + if (shown != target) { + cardLayout.show(cards, target.name) + shown = target + revalidate() + repaint() + } + } + + private fun targetCard(): Card { + val s = status + val p = prof + // When loading/connecting and already showing the logged-in card, stay on it to + // avoid focus loss during reconnects, initial loads, and org switches. + val transientLoad = s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING + if (transientLoad && shown == Card.LOGGED_IN) return Card.LOGGED_IN + return when { + s == KiloAppStatusDto.DISCONNECTED || transientLoad -> Card.LOGGED_OUT + s == KiloAppStatusDto.ERROR -> Card.LOGGED_OUT + p == null -> Card.LOGGED_OUT + else -> Card.LOGGED_IN + } + } + + @RequiresEdt + private fun applyState() { + checkEdt() + update(app.state.value) + } + + /** + * Invalidate any pending login flows and dispose the logged-out UI timer. + * Called from [ai.kilocode.client.settings.profile.UserProfileConfigurable.disposeUIResources]. + */ + @RequiresEdt + fun dispose() { + attempt++ + out.dispose() + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { + "ProfileUi updates must run on EDT" + } + } + + private fun start() { + val id = ++attempt + login = LoginState.Initiating + sync() + cs.launch { + try { + val next = app.startLogin() + withContext(edt) { + if (id != attempt) return@withContext + login = LoginState.Pending(next, System.currentTimeMillis()) + sync() + browse(next.verificationUrl) + } + val profile = app.completeLogin() + val state = app.state.value + withContext(edt) { + if (id != attempt) return@withContext + login = LoginState.Idle + update(profile ?: state.profile, state.status) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + if (id != attempt) return@withContext + login = LoginState.Error(compactLoginError(e)) + sync() + } + } + } + } + + private fun cancel() { + attempt++ + login = LoginState.Idle + sync() + } + + private fun logout() { + cs.launch { + try { + val ok = app.logout() + if (!ok) return@launch + withContext(edt) { + login = LoginState.Idle + applyState() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + } + } + } + } + + private fun organization(org: String?) { + cs.launch { + try { + val profile = app.setOrganization(org) + val state = app.state.value + withContext(edt) { + update(profile ?: state.profile, state.status) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + } + } + } + } + + private fun refreshProfile() { + cs.launch { + try { + val profile = app.refreshProfile() + val state = app.state.value + withContext(edt) { + update(profile ?: state.profile, state.status) + account.setRefreshing(false) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + account.setRefreshing(false) + } + } + } + } +} + +private val HTML_MARKERS = listOf(" Tools -> Kilo -> User Profile. + * + * Shows login / logout, current balance, personal/org account selector, + * and a link to the Kilo dashboard. This is a status/action panel — it + * has no persistent settings, so [isModified] always returns false. + */ +class UserProfileConfigurable : SearchableConfigurable { + + private var ui: JComponent? = null + private var scope: CoroutineScope? = null + private var watchJob: Job? = null + private var focus = false + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.profile.displayName") + + override fun getPreferredFocusedComponent(): JComponent? = (ui as? ProfileUi)?.preferredFocus() + + override fun focusOn(label: String) { + if (label != FOCUS_ACCOUNT_COMBO) return + focus = true + val panel = ui as? ProfileUi ?: return + requestFocus(panel) + } + + override fun createComponent(): JComponent { + val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) + scope = cs + val panel = buildPanel(cs) + ui = panel + startWatching(cs, panel) + if (focus) requestFocus(panel) + return panel + } + + private fun requestFocus(panel: ProfileUi) { + val app = ApplicationManager.getApplication() + app.invokeLater({ + app.invokeLater({ + val target = panel.preferredFocus() + if (target.isShowing) IdeFocusManager.getGlobalInstance().requestFocus(target, true) + }, ModalityState.any()) + }, ModalityState.any()) + } + + private fun buildPanel(cs: CoroutineScope): ProfileUi { + val app = service() + return ProfileUi(app.state.value.profile, app.state.value.status, cs) + } + + private fun startWatching(cs: CoroutineScope, panel: ProfileUi) { + val app = service() + watchJob = cs.launch { + app.state.collect { state -> + withContext(edt) { + panel.update(state) + } + } + } + cs.launch { + app.connect() + } + } + + override fun isModified(): Boolean = false + + override fun apply() = Unit + + override fun reset() = Unit + + override fun disposeUIResources() { + // Dispose UI first to invalidate pending login attempts before scope cancellation. + // Capturing local refs before nulling fields so the EDT callback is self-contained. + val panel = ui as? ProfileUi + val job = watchJob + val cs = scope + ui = null + watchJob = null + scope = null + + val app = ApplicationManager.getApplication() + if (panel != null) { + if (app.isDispatchThread) { + panel.dispose() + job?.cancel() + cs?.cancel() + } else { + // Schedule on EDT so dispose runs before scope cancel, as the plan requires. + app.invokeLater({ + panel.dispose() + job?.cancel() + cs?.cancel() + }, ModalityState.any()) + } + } else { + job?.cancel() + cs?.cancel() + } + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.profile" + const val FOCUS_ACCOUNT_COMBO = "kilo.profile.account.combo" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt deleted file mode 100644 index 57e7d95e5f4..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ai.kilocode.client.ui - -import java.awt.Component -import java.awt.Dimension -import javax.swing.JPanel - -/** - * Centers its single child and shrinks it to available space when needed. - * If available space is larger than the child's maximum size, the child is not expanded. - */ -class CenterShrinkPanel(child: Component) : JPanel(null) { - init { - isOpaque = false - add(child) - } - - override fun doLayout() { - if (componentCount == 0) return - val child = getComponent(0) - val insets = getInsets() - val availW = width - insets.left - insets.right - val availH = height - insets.top - insets.bottom - val pref = child.preferredSize - val max = child.maximumSize - val w = minOf(pref.width, max.width, availW) - val h = minOf(pref.height, max.height, availH) - val x = insets.left + (availW - w) / 2 - val y = insets.top + (availH - h) / 2 - child.setBounds(x, y, w, h) - } - - override fun getPreferredSize(): Dimension { - if (componentCount == 0) return super.getPreferredSize() - val pref = getComponent(0).preferredSize - val insets = getInsets() - return Dimension(pref.width + insets.left + insets.right, pref.height + insets.top + insets.bottom) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt new file mode 100644 index 00000000000..1882f0a9ee3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.ui + +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JPanel + +internal class DiffStatBadge( + additions: Int, + deletions: Int, +) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)) { + private val removed = JBLabel("-$deletions").apply { + foreground = removedColor() + font = JBFont.small() + } + private val added = JBLabel("+$additions").apply { + foreground = addedColor() + font = JBFont.small() + } + + init { + isOpaque = false + add(removed) + add(added) + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = backgroundColor() + g2.fillRoundRect(0, 0, width, height, height, height) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + internal fun removedLabelForTest() = removed + + internal fun addedLabelForTest() = added +} + +private fun backgroundColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.background", + JBColor(Color(0x26, 0x26, 0x26), Color(0x26, 0x26, 0x26)), +) + +private fun removedColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.removedForeground", + JBColor(Color(0xdb, 0x58, 0x66), Color(0xff, 0x6b, 0x7a)), +) + +private fun addedColor(): Color = JBColor.namedColor( + "Kilo.DiffStat.addedForeground", + JBColor(Color(0x1f, 0x9d, 0x66), Color(0x35, 0xd4, 0x9a)), +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt new file mode 100644 index 00000000000..7c740167225 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.ui + +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.font.FontRenderContext +import javax.swing.Icon + +internal class FilledBadgeIcon( + private val text: String, + private val bg: Color, + private val fg: Color, +) : Icon { + override fun getIconWidth(): Int { + val font = JBFont.small() + val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() + return width + UiStyle.Gap.lg() * 2 + } + + override fun getIconHeight() = 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 = bg + g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight) + g2.color = fg + g2.font = JBFont.small() + val fm = g2.fontMetrics + val base = (iconHeight + fm.ascent - fm.descent) / 2 + g2.drawString(text, UiStyle.Gap.lg(), base) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt index 6a08c5bb9ce..86b1b6763ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt @@ -1,22 +1,19 @@ package ai.kilocode.client.ui -import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.UIManager open class PickerButton : JBLabel() { private var over = false init { border = pickerBorder() - background = picker() + background = UiStyle.Colors.picker() // The custom rounded fill needs parent background around the corners. isOpaque = false addMouseListener(object : MouseAdapter() { @@ -33,14 +30,14 @@ open class PickerButton : JBLabel() { override fun updateUI() { super.updateUI() border = pickerBorder() - background = picker() + background = UiStyle.Colors.picker() } override fun paintComponent(g: Graphics) { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else picker() + g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else UiStyle.Colors.picker() val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) g2.fillRoundRect(0, 0, width, height, arc, arc) } finally { @@ -55,11 +52,5 @@ open class PickerButton : JBLabel() { repaint() } - private fun picker() = JBColor.lazy { - UIManager.getColor("ComboBoxButton.background") - ?: UIManager.getColor("ComboBox.nonEditableBackground") - ?: UIUtil.getPanelBackground() - } - private fun pickerBorder() = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.lg()) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt new file mode 100644 index 00000000000..ae1d5dde543 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt @@ -0,0 +1,61 @@ +package ai.kilocode.client.ui + +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Color +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints + +open class RoundedContentPanel( + top: Int, + left: Int, + bottom: Int = top, + right: Int = left, +) : BorderLayoutPanel() { + + init { + isOpaque = false + background = contentColor() + border = JBUI.Borders.empty(top, left, bottom, right) + } + + override fun updateUI() { + super.updateUI() + isOpaque = false + background = contentColor() + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON, + ) + val arc = cornerArc() + g2.color = contentColor() + g2.fillRoundRect(0, 0, width, height, arc, arc) + val line = outlineColor() + if (line != null) { + g2.color = line + for (idx in 0 until outlineWidth()) { + val w = width - idx * 2 - 1 + val h = height - idx * 2 - 1 + if (w > 0 && h > 0) g2.drawRoundRect(idx, idx, w, h, arc, arc) + } + } + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + protected open fun contentColor(): Color = UiStyle.Colors.cardBg() + + protected open fun outlineColor(): Color? = UiStyle.Colors.cardBorder() + + protected open fun outlineWidth(): Int = JBUI.scale(1) + + protected open fun cornerArc(): Int = UiStyle.Arc.component() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 850ab9f84dd..156d0760338 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.ui import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.ui.JBColor +import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color @@ -15,13 +16,21 @@ object UiStyle { object Gap { fun xs() = JBUI.scale(2) + fun sm() = JBUI.scale(4) + fun md() = JBUI.scale(6) fun lg() = JBUI.scale(8) - fun sm() = JBUI.scale(4) - fun pad() = JBUI.scale(12) + + fun xl() = JBUI.scale(16) + } + + /** Theme-aware component geometry tokens. */ + object Arc { + /** Standard component corner arc, matching the platform's `Component.arc` key. */ + fun component() = com.intellij.util.ui.JBValue.UIInteger("Component.arc", 8).get() } /** Theme-aware colors and color math used by multiple UI surfaces. */ @@ -35,6 +44,45 @@ object UiStyle { /** Uses the editor background so chat cards feel native beside editor content. */ fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } + /** + * Card surface background: follows the active theme's text-field/input surface. + * Uses [UIUtil.getTextFieldBackground] as the semantic platform surface color for + * contained panels. Falls back to the panel background when unavailable. + */ + fun cardBg(): Color = JBColor.lazy { + UIManager.getColor("TextField.background") ?: UIUtil.getPanelBackground() + } + + /** Standard picker/combobox surface, contrasted against the default panel background by the active theme. */ + fun picker(): Color = JBColor.lazy { + UIManager.getColor("ComboBoxButton.background") + ?: UIManager.getColor("ComboBox.nonEditableBackground") + ?: UIUtil.getPanelBackground() + } + + /** Filled badge surface using platform badge/info colors with a soft theme-derived fallback. */ + fun badgeBg(): Color = JBColor.lazy { + UIManager.getColor("Badge.background") + ?: UIManager.getColor("Label.infoBackground") + ?: blend(cardBg(), fg(), 0.16f) + } + + /** Filled badge text color paired with [badgeBg]. */ + fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground()) + + /** Card border color shared across profile cards. */ + fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border()) + + /** + * Floating panel background: white in light themes, black in dark themes. + * Used for account switcher popup panels and any overlay panels that need + * a high-contrast base distinct from the standard editor/sidebar background. + */ + fun floatingPanel(): Color = JBColor.namedColor( + "Kilo.FloatingPanel.background", + JBColor(java.awt.Color.WHITE, java.awt.Color.BLACK), + ) + fun errorLabelForeground(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) fun warningLabelForeground(): Color = JBColor.lazy { @@ -67,6 +115,38 @@ object UiStyle { (color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) >= 128 } + /** + * Platform typography tokens for use throughout the plugin. + * + * Use these instead of [java.awt.Font.deriveFont] with manual size multipliers. + * All values delegate to [JBFont] helpers which scale with the platform default font. + */ + object Fonts { + /** Large display value, e.g. account balance. Maps to [JBFont.h1] bold. */ + fun display(): JBFont = JBFont.h1().asBold() + + /** Page/section heading, e.g. login card title. Maps to [JBFont.h3] bold. */ + fun heading(): JBFont = JBFont.h3().asBold() + + /** Prominent short content, e.g. device auth code. Maps to [JBFont.h2] bold. */ + fun large(): JBFont = JBFont.h2().asBold() + + /** Card/question header font — bold at heading level 4. */ + fun header(): JBFont = JBFont.h4().asBold() + + /** Hint or description font — plain regular size. */ + fun hint(): JBFont = JBFont.regular() + + /** Standard body/label text. */ + fun regular(): JBFont = JBFont.regular() + + /** Bold body/label text. */ + fun bold(): JBFont = JBFont.regular().asBold() + + /** Small secondary text, e.g. metadata labels. */ + fun small(): JBFont = JBFont.small() + } + /** Small component helpers that keep repeated Swing setup in one place. */ object Components { fun transparent(vararg components: JComponent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt new file mode 100644 index 00000000000..e811fc8a7f8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt @@ -0,0 +1,135 @@ +package ai.kilocode.client.ui.layout + +import java.awt.Component +import java.awt.Dimension +import javax.swing.JPanel + +enum class HAlign { TRACK, FIT, LEFT, CENTER, RIGHT } +enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM } + +/** + * A transparent wrapper panel that positions its single child according to independent + * horizontal ([h]) and vertical ([v]) alignment modes. + * + * **TRACK**: child fills all available space on that axis, ignoring child min/preferred/max. + * The wrapper reports zero contribution from the child on that axis for its own min/preferred/max. + * + * **FIT**: child fills available space clamped to child's effective [min, max] range. + * + * **LEFT / CENTER / RIGHT** (horizontal) and **TOP / CENTER / BOTTOM** (vertical): + * child uses its bounded preferred size (coerced into [min, max]) and is placed at the + * corresponding edge or centered. Shrinks to available space when necessary. + * + * Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution + * (zero for TRACK axes) with the panel insets. + * + * Use the factory extension for concise call sites: + * ``` + * label.align(HAlign.CENTER, VAlign.CENTER) + * button.align(HAlign.RIGHT, VAlign.CENTER) + * panel.align(HAlign.LEFT, VAlign.TOP) + * scrollable.align(HAlign.TRACK, VAlign.TOP) + * ``` + */ +class Align( + child: Component, + private val h: HAlign = HAlign.FIT, + private val v: VAlign = VAlign.FIT, +) : JPanel(null) { + + init { + isOpaque = false + add(child) + } + + // ----------------------------------------------------------------------- + // Layout + // ----------------------------------------------------------------------- + + override fun doLayout() { + if (componentCount == 0) return + val child = getComponent(0) + val ins = insets + val availW = maxOf(0, width - ins.left - ins.right) + val availH = maxOf(0, height - ins.top - ins.bottom) + + val (w, cx) = placeAxis(h, availW, child.minimumSize.width, child.preferredSize.width, child.maximumSize.width) + val (ht, cy) = placeAxis(v, availH, child.minimumSize.height, child.preferredSize.height, child.maximumSize.height) + + child.setBounds(ins.left + cx, ins.top + cy, w, ht) + } + + // ----------------------------------------------------------------------- + // Wrapper size negotiation + // ----------------------------------------------------------------------- + + override fun getMinimumSize(): Dimension { + if (componentCount == 0) return super.getMinimumSize() + val child = getComponent(0) + val ins = insets + val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width + val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height + return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) + } + + override fun getPreferredSize(): Dimension { + if (componentCount == 0) return super.getPreferredSize() + val child = getComponent(0) + val ins = insets + val cw = if (h == HAlign.TRACK) 0 else bounded(child.preferredSize.width, child.minimumSize.width, child.maximumSize.width) + val ch = if (v == VAlign.TRACK) 0 else bounded(child.preferredSize.height, child.minimumSize.height, child.maximumSize.height) + return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) + } + + override fun getMaximumSize(): Dimension { + if (componentCount == 0) return super.getMaximumSize() + val child = getComponent(0) + val ins = insets + val cw = if (h == HAlign.TRACK) super.getMaximumSize().width else maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right + val ch = if (v == VAlign.TRACK) super.getMaximumSize().height else maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom + return Dimension(cw, ch) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Returns (size, offset) for a single axis. Offset is relative to the inner origin (after insets). + * - TRACK: size = avail, offset = 0 + * - FIT: size = clamp(avail, min, max), offset = 0 + * - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment + */ +private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair { + val effMax = maxOf(min, max) + return when (mode) { + HAlign.TRACK, VAlign.TRACK -> avail to 0 + HAlign.FIT, VAlign.FIT -> { + // fill available, capped at effMax; if avail < min we still shrink to avail + val size = minOf(avail, effMax) + size to 0 + } + HAlign.LEFT, VAlign.TOP -> { + val size = minOf(bounded(pref, min, effMax), avail) + size to 0 + } + HAlign.CENTER, VAlign.CENTER -> { + val size = minOf(bounded(pref, min, effMax), avail) + size to (avail - size) / 2 + } + HAlign.RIGHT, VAlign.BOTTOM -> { + val size = minOf(bounded(pref, min, effMax), avail) + size to (avail - size) + } + else -> avail to 0 + } +} + +private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max)) + +// --------------------------------------------------------------------------- +// Factory extension +// --------------------------------------------------------------------------- + +fun Component.align(h: HAlign, v: VAlign) = Align(this, h, v) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 2f299325292..1135022c543 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.ui.md +import ai.kilocode.client.ui.UiStyle import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration @@ -355,7 +356,11 @@ abstract class MdView private constructor() { linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") } - preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preBgOverride?.let { + val color = hex(it) + rules.append("div.code-block { background: $color; border-color: $color; padding: ${UiStyle.Gap.xs()}px ${UiStyle.Gap.lg()}px } ") + rules.append("pre { background: $color; border-color: $color } ") + } preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg new file mode 100644 index 00000000000..a8f3398dfe1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index ce147ef1f54..080953e15f5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -14,6 +14,20 @@ icon="/icons/kilo.svg" factoryClass="ai.kilocode.client.KiloToolWindowFactory"/> + + + + + + + + + + + + + + + + = emptyList(), + currentOrgId: String? = null, + ) = ProfileDto(email = email, name = name, organizations = orgs, balance = balance, currentOrgId = currentOrgId) + + // ------ refreshProfile ------ + + fun `test refreshProfile updates app state profile on success`() = runBlocking(Dispatchers.Default) { + rpc.fakeProfile = profile() + val result = app.refreshProfile() + assertNotNull(result) + assertEquals("alice@test.com", result!!.email) + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + fun `test refreshProfile returns null and leaves existing state on exception`() = runBlocking(Dispatchers.Default) { + val existing = profile(email = "existing@test.com") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = existing) + rpc.refreshError = RuntimeException("refresh failed") + val result = app.refreshProfile() + assertNull(result) + assertEquals("existing@test.com", app.state.value.profile?.email) + } + + // ------ completeLogin ------ + + fun `test completeLogin updates app state profile on success`() = runBlocking(Dispatchers.Default) { + rpc.fakeProfile = profile() + val result = app.completeLogin("/my/dir") + assertNotNull(result) + assertEquals("alice@test.com", result!!.email) + assertEquals("alice@test.com", app.state.value.profile?.email) + assertEquals(listOf("/my/dir"), rpc.completeDirectories) + } + + fun `test completeLogin returns null on exception without clearing previous profile`() = runBlocking(Dispatchers.Default) { + val existing = profile(email = "existing@test.com") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = existing) + rpc.completeError = RuntimeException("complete failed") + val result = app.completeLogin("/dir") + assertNull(result) + assertEquals("existing@test.com", app.state.value.profile?.email) + } + + // ------ logout ------ + + fun `test logout clears profile when rpc returns true`() = runBlocking(Dispatchers.Default) { + val prof = profile() + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + rpc.fakeProfile = prof + rpc.logoutResult = true + val ok = app.logout() + assertTrue(ok) + assertNull(app.state.value.profile) + } + + fun `test logout does not clear profile when rpc returns false`() = runBlocking(Dispatchers.Default) { + val prof = profile() + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + rpc.logoutResult = false + val ok = app.logout() + assertFalse(ok) + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + fun `test logout returns false on exception`() = runBlocking(Dispatchers.Default) { + val prof = profile() + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + rpc.logoutError = RuntimeException("logout failed") + val ok = app.logout() + assertFalse(ok) + // Profile should be unchanged since logout threw + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + // ------ setOrganization ------ + + fun `test setOrganization updates profile on success for org id`() = runBlocking(Dispatchers.Default) { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = profile(orgs = orgs) + rpc.fakeProfile = personal + val org = personal.copy(currentOrgId = "org_1") + rpc.orgProfiles["org_1"] = org + val result = app.setOrganization("org_1") + assertNotNull(result) + assertEquals("org_1", result!!.currentOrgId) + assertEquals(listOf("org_1"), rpc.orgSelections) + assertEquals("org_1", app.state.value.profile?.currentOrgId) + } + + fun `test setOrganization updates profile for personal null selection`() = runBlocking(Dispatchers.Default) { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val org = profile(orgs = orgs, currentOrgId = "org_1") + rpc.fakeProfile = org + val personal = profile(orgs = orgs, currentOrgId = null) + rpc.orgProfiles[null] = personal + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = org) + val result = app.setOrganization(null) + assertNotNull(result) + assertNull(result!!.currentOrgId) + assertEquals(listOf(null), rpc.orgSelections) + } + + fun `test setOrganization returns null on exception without changing profile`() = runBlocking(Dispatchers.Default) { + val existing = profile(email = "alice@test.com") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = existing) + rpc.organizationError = RuntimeException("org failed") + val result = app.setOrganization("org_1") + assertNull(result) + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + // ------ startLogin / completeLogin directory forwarding ------ + + fun `test startLogin forwards directory`() = runBlocking(Dispatchers.Default) { + app.startLogin("/workspace") + assertEquals(listOf("/workspace"), rpc.startDirectories) + } + + fun `test completeLogin forwards directory`() = runBlocking(Dispatchers.Default) { + rpc.fakeProfile = profile() + app.completeLogin("/workspace") + assertEquals(listOf("/workspace"), rpc.completeDirectories) + } + + fun `test startLogin with null directory is forwarded`() = runBlocking(Dispatchers.Default) { + app.startLogin(null) + assertEquals(listOf(null), rpc.startDirectories) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 337be345589..fb1dac9898d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -2,8 +2,13 @@ package ai.kilocode.client.session import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.ToolRefDto import com.intellij.util.ui.JBUI import kotlinx.coroutines.CompletableDeferred @@ -328,4 +333,79 @@ class SessionScrollTest : SessionUiTestBase() { assertSame(scrollComponent(), scrollView()?.parent?.parent) assertFalse(scrollView() is SessionMessageListPanel) } + + // ------ question/login-required autoscroll ------ + + fun `test question appearing at bottom keeps scroll at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.QuestionAsked("ses_test", question("q_at_bottom"))) + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question appearing while user is in middle preserves scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.QuestionAsked("ses_test", question("q_middle"))) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test login required appearing at bottom keeps scroll at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body))) + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test login required appearing while user is in middle preserves scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body))) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + // ------ helpers ------ + + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = listOf(QuestionOptionDto("A", "Option A")), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index b3256c446d4..b1460cc8be5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -11,11 +11,18 @@ import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerEvent -import ai.kilocode.client.session.views.PermissionView +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileDto +import com.intellij.util.ui.JBUI +import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.rpc.dto.MessageWithPartsDto import com.intellij.ui.components.JBScrollPane @@ -45,7 +52,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(root.content, stack.parent) assertSame(stack, connection.parent) - assertEquals(1, root.overlay.componentCount) + assertTrue(root.overlay.components.any { it is SessionAccountOverlay }) assertEquals(listOf(connection, prompt), stack.components.toList()) } @@ -352,4 +359,79 @@ class SessionUiLayoutTest : SessionUiTestBase() { meta = PermissionMeta(raw = emptyMap()), ) ) + + // --- account overlay layout tests --- + + fun `test account overlay is registered in root overlay layer`() { + val root = find(ui) + val overlay = find(ui) + + assertSame(root.overlay, overlay.parent) + } + + fun `test account overlay hidden before recents complete`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + + settleShort(100) + + val overlay = find(ui) + assertFalse(overlay.isVisible) + + rpc.recentGate!!.complete(Unit) + } + + fun `test account overlay shows after recents complete`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + + settle() + + val overlay = find(ui) + assertTrue(overlay.isVisible) + } + + fun `test account overlay hides after first prompt`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + settle() + + val overlay = find(ui) + assertTrue(overlay.isVisible) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + settle() + + assertFalse(overlay.isVisible) + } + + fun `test explicit session does not show overlay`() { + ui = newUi(id = "ses_test") + settle() + + val overlay = find(ui) + assertFalse(overlay.isVisible) + } + + fun `test account overlay uses prompt panel top and right insets`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + settle() + layout() + + val root = find(ui) + val overlay = find(ui) + val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING) + val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING) + + assertTrue(overlay.isVisible) + assertEquals(top, overlay.y) + assertEquals(root.overlay.width - overlay.width - right, overlay.x) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 59687514476..66caf1cb8f7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -45,6 +45,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { protected lateinit var app: KiloAppService protected lateinit var workspaces: KiloWorkspaceService protected lateinit var rpc: FakeSessionRpcApi + protected lateinit var appRpc: FakeAppRpcApi protected lateinit var workspace: Workspace protected lateinit var ui: SessionUi @@ -53,7 +54,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { scope = CoroutineScope(SupervisorJob()) rpc = FakeSessionRpcApi() - val appRpc = FakeAppRpcApi().also { + appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } val workspaceRpc = FakeWorkspaceRpcApi().also { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index 5d8d5350a81..baefa04e2be 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -35,6 +35,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { // ViewChanged progress fires immediately on controller construction (step 3 of plan). // ViewChanged session fires after non-empty history is loaded. assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress @@ -62,6 +63,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.isEmpty()) assertModelEvents("HistoryLoaded", modelEvents) assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt index fa0e118ba2c..682a61fbd47 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt @@ -24,6 +24,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { flush() assertControllerEvents(""" + AccountOverlayChanged hide ViewChanged session AppChanged WorkspaceChanged @@ -47,6 +48,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { assertEquals(events1, events2) assertControllerEvents(""" + AccountOverlayChanged hide ViewChanged session AppChanged WorkspaceChanged diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PaidModelAuthTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PaidModelAuthTest.kt new file mode 100644 index 00000000000..d7151c71d34 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PaidModelAuthTest.kt @@ -0,0 +1,87 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.MessageErrorDto +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pure unit tests for [isPaidModelAuthRequired]. + * No IntelliJ platform setup needed — the function is entirely pure. + */ +class PaidModelAuthTest { + + private fun error( + type: String = "APIError", + statusCode: Int? = 401, + responseBody: String? = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""", + ) = MessageErrorDto(type = type, statusCode = statusCode, responseBody = responseBody) + + @Test + fun `null error returns false`() { + assertFalse(isPaidModelAuthRequired(null)) + } + + @Test + fun `wrong type returns false`() { + assertFalse(isPaidModelAuthRequired(error(type = "NetworkError"))) + } + + @Test + fun `missing status code returns false`() { + assertFalse(isPaidModelAuthRequired(error(statusCode = null))) + } + + @Test + fun `wrong status code returns false`() { + assertFalse(isPaidModelAuthRequired(error(statusCode = 403))) + } + + @Test + fun `missing response body returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = null))) + } + + @Test + fun `malformed response body returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = "not json {"))) + } + + @Test + fun `nested error code returns true`() { + assertTrue(isPaidModelAuthRequired(error(responseBody = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}"""))) + } + + @Test + fun `top level code returns true`() { + assertTrue(isPaidModelAuthRequired(error(responseBody = """{"code":"PAID_MODEL_AUTH_REQUIRED"}"""))) + } + + @Test + fun `unknown code returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = """{"error":{"code":"SOME_OTHER_ERROR"}}"""))) + } + + @Test + fun `response body with extra unknown fields still returns true`() { + assertTrue( + isPaidModelAuthRequired( + error(responseBody = """{"requestId":"abc","error":{"code":"PAID_MODEL_AUTH_REQUIRED","message":"Login required"}}"""), + ), + ) + } + + @Test + fun `empty json object returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = "{}"))) + } + + @Test + fun `nested code does not match wrong value`() { + assertFalse( + isPaidModelAuthRequired( + error(responseBody = """{"error":{"code":"UNAUTHORIZED"}}"""), + ), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 727f527b25f..aa095e0a136 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -1,8 +1,12 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.session.model.PermissionFileDiff +import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto +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.QuestionInfoDto @@ -10,6 +14,7 @@ import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto +import com.intellij.ide.util.PropertiesComponent class PromptLifecycleTest : SessionControllerTestBase() { @@ -175,6 +180,150 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("q1", rpc.questionRejects[0].first) } + fun `test PermissionAsked maps rich fields to meta`() { + val (m, _, _) = prompted() + val req = PermissionRequestDto( + id = "perm_rich", + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + command = "git diff", + fileDiffs = listOf(PermissionFileDiffDto("src/A.kt", patch = "@@ @@", additions = 1, deletions = 0)), + ) + + emit(ChatEventDto.PermissionAsked("ses_test", req)) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("git diff", perm.meta.command) + assertEquals(1, perm.meta.fileDiffs.size) + assertEquals("src/A.kt", perm.meta.fileDiffs[0].file) + } + + fun `test replyPermission without rules leaves rulesSaved empty`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + edt { m.replyPermission("perm1", PermissionReplyDto("once")) } + flush() + + assertTrue(rpc.permissionRulesSaved.isEmpty()) + assertEquals(1, rpc.permissionReplies.size) + } + + // ------ Child session (subagent) permission bubbling ------ + + fun `test task part with child sessionId causes controller to track child`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("child_perm1", perm.id) + assertEquals("ses_child", perm.sessionId) + } + + fun `test child PermissionAsked moves root model to AwaitingPermission`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + assertSession( + """ + permission#child_perm1 + tool: + name: edit + patterns: *.kt + always: + file: + state: PENDING + metadata: + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, + ) + } + + fun `test child PermissionReplied clears root awaiting permission`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")), flush = false) + emit(ChatEventDto.PermissionReplied("ses_child", "child_perm1")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test replyPermission for child request sends correct requestId`() { + val (m, _, _) = prompted() + + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + + edt { m.replyPermission("child_perm1", PermissionReplyDto("once")) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("child_perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + } + + fun `test child non-permission events do not change root state`() { + val (m, _, modelEvents) = prompted() + val initialState = m.model.state + + // Emit non-permission child events — they must not affect the root + emit(ChatEventDto.TurnOpen("ses_child"), flush = false) + emit(ChatEventDto.SessionStatusChanged("ses_child", ai.kilocode.rpc.dto.SessionStatusDto("busy")), flush = false) + emit(ChatEventDto.SessionIdle("ses_child")) + + assertEquals(initialState, m.model.state) + // No extra model state events from child non-permission events + val stateEvents = modelEvents.filterIsInstance() + assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty()) + } + + fun `test root permission event is not processed as child permission`() { + val (m, _, _) = prompted() + + // No task part emitted — root permission should still work + emit(ChatEventDto.PermissionAsked("ses_test", permission("root_perm"))) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("root_perm", perm.id) + } + + private fun taskPart(childSessionId: String) = ChatEventDto.PartUpdated( + sessionID = "ses_test", + part = PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to childSessionId), + ), + ) + + private fun childPermission(id: String) = PermissionRequestDto( + id = id, + sessionID = "ses_child", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + ) + private fun permission(id: String) = PermissionRequestDto( id = id, sessionID = "ses_test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 249d51e7bc2..51bd2bf2e4e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -248,6 +248,13 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { ApplicationManager.getApplication().invokeAndWait(block) } + protected fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + /** Emit a chat event into the fake RPC flow. */ protected fun emit(event: ChatEventDto, flush: Boolean = true) { runBlocking { rpc.events.emit(event) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index e31795516c5..8cb0c1f3507 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -233,6 +235,107 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) } + // ------ Child session permission recovery from history ------ + + fun `test history with task part and pending child permission recovers to AwaitingPermission`() { + rpc.history.add( + MessageWithPartsDto( + info = msg("msg1", "ses_test", "assistant"), + parts = listOf( + PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to "ses_child"), + ), + ), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "child_perm_1", + sessionID = "ses_child", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("child_perm_1", perm.id) + assertEquals("ses_child", perm.sessionId) + } + + fun `test pending child permission from unrelated session is ignored`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_unrelated", + sessionID = "ses_other_child", + permission = "read", + patterns = emptyList(), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // No task part linking ses_other_child — its permissions must be ignored + assertEquals(SessionState.Idle, m.model.state) + } + + fun `test root pending permission takes priority over child pending permission`() { + rpc.history.add( + MessageWithPartsDto( + info = msg("msg1", "ses_test", "assistant"), + parts = listOf( + PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to "ses_child"), + ), + ), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "root_perm", + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + ) + ) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "child_perm", + sessionID = "ses_child", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // Root recovery runs first and sets AwaitingPermission for root perm + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("root_perm", perm.id) + } + fun `test pending question overrides a seeded retry status`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 1, next = 1000L)) rpc.pendingQuestionList.add( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index bff862e380d..841d2cc0963 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -2,7 +2,14 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SessionStatusDto class TurnLifecycleTest : SessionControllerTestBase() { @@ -147,6 +154,226 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test paid model auth error enters login required state`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + + assertTrue(m.model.state is SessionState.LoginRequired) + assertSession( + """ + [code] [kilo/gpt-5] [login-required] [Go to User Profile settings to sign in, then continue this session.] + """, + m, + ) + } + + fun `test paid model auth error opens empty new session`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + val m = controller() + flush() + edt { m.prompt("go") } + flush() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + + assertSession( + """ + [code] [kilo/gpt-5] [login-required] [Go to User Profile settings to sign in, then continue this session.] + """, + m, + ) + } + + fun `test normal api error remains generic error`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"SOME_OTHER_CODE"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Bad Request", statusCode = 400, responseBody = body), + )) + + assertTrue(m.model.state is SessionState.Error) + assertSession( + """ + [code] [kilo/gpt-5] [error] [Bad Request] + """, + m, + ) + } + + fun `test login clears paid model gate`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + assertTrue(m.model.state is SessionState.LoginRequired) + + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/gpt-5"), + profile = ProfileDto(email = "user@example.com"), + ) + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + assertTrue(m.model.showSession) + } + + fun `test login resumes paid model prompt`() { + val (m, _, _) = prompted() + val msg = MessageDto( + id = "msg_user", + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + agent = "code", + providerID = "kilo/openai", + modelID = "gpt-5.5", + ) + emit(ChatEventDto.MessageUpdated("ses_test", msg)) + emit(ChatEventDto.PartUpdated( + "ses_test", + PartDto("prt_user", "ses_test", "msg_user", "text", text = "try again"), + )) + rpc.prompts.clear() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/gpt-5"), + profile = ProfileDto(email = "user@example.com"), + ) + flush() + + assertEquals(1, rpc.prompts.size) + val prompt = rpc.prompts.single().third + assertEquals("msg_user", prompt.messageID) + assertEquals(false, prompt.noReply) + assertEquals("code", prompt.agent) + assertEquals("kilo/openai", prompt.providerID) + assertEquals("gpt-5.5", prompt.modelID) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test session idle does not clobber login required`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + emit(ChatEventDto.SessionIdle("ses_test")) + + assertTrue(m.model.state is SessionState.LoginRequired) + } + + fun `test session status idle does not clobber login required`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertTrue(m.model.state is SessionState.LoginRequired) + } + + fun `test turn close error does not clobber login required`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + emit(ChatEventDto.TurnClose("ses_test", "error")) + + assertTrue(m.model.state is SessionState.LoginRequired) + } + + fun `test dismissLoginRequired transitions state to idle`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + assertTrue(m.model.state is SessionState.LoginRequired) + + edt { m.dismissLoginRequired() } + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test dismissLoginRequired clears retry so login does not resume prompt`() { + val (m, _, _) = prompted() + val msg = MessageDto( + id = "msg_user", + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + agent = "code", + providerID = "kilo/openai", + modelID = "gpt-5.5", + ) + emit(ChatEventDto.MessageUpdated("ses_test", msg)) + rpc.prompts.clear() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + assertTrue(m.model.state is SessionState.LoginRequired) + + edt { m.dismissLoginRequired() } + flush() + + // profile becomes available, but there should be no auto-retry + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/gpt-5"), + profile = ProfileDto(email = "user@example.com"), + ) + flush() + + assertEquals("retry should not have fired after dismiss", 0, rpc.prompts.size) + assertTrue("state should be idle after dismiss + profile available", m.model.state is SessionState.Idle) + } + fun `test events for wrong session are ignored`() { val (m, _, modelEvents) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt index ae2a2f2ea72..fecf38da614 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt @@ -2,6 +2,11 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileBalanceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileOrganizationDto import kotlinx.coroutines.CompletableDeferred class ViewSwitchingTest : SessionControllerTestBase() { @@ -62,6 +67,8 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) assertControllerEvents(""" + AccountOverlayChanged hide + AccountOverlayChanged show loggedIn=false AppChanged WorkspaceChanged WorkspaceReady @@ -79,6 +86,8 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) assertControllerEvents(""" + AccountOverlayChanged hide + AccountOverlayChanged show loggedIn=false AppChanged WorkspaceChanged WorkspaceReady @@ -95,6 +104,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.isEmpty()) assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress @@ -347,4 +357,133 @@ class ViewSwitchingTest : SessionControllerTestBase() { version = "1", time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), ) + + // --- account overlay controller tests --- + + fun `test empty session with workspace ready emits account overlay show`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + val show = events.filterIsInstance().last() + assertEquals("AccountOverlayChanged show loggedIn=false", show.toString()) + } + + fun `test empty session overlay show includes logged in profile`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val prof = ProfileDto( + email = "user@example.com", + name = "Test User", + balance = ProfileBalanceDto(10.0), + ) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + val m = controller() + val events = collect(m) + + flush() + + val show = events.filterIsInstance().last() + assertEquals("AccountOverlayChanged show loggedIn=true", show.toString()) + assertEquals(prof.email, show.account.profile?.email) + } + + fun `test first prompt hides overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + flush() + val events = collect(m) + + edt { m.prompt("hello") } + flush() + + assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Hide }) + assertFalse(events.filterIsInstance().any { it.account.profile != null }) + } + + fun `test explicit local session load never shows overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller("ses_test") + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test explicit cloud import never shows overlay`() { + projectRpc.state.value = workspaceReady() + rpc.importedCloudSession = session("ses_imported") + rpc.recent.add(session("ses_1")) + val m = controller("cloud:cloud_1") + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test app profile change refreshes overlay while allowed`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + flush() + + val prof = ProfileDto(email = "user@example.com", balance = ProfileBalanceDto(20.0)) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + flush() + + val shows = events.filterIsInstance() + assertTrue(shows.isNotEmpty()) + assertTrue(shows.last().account.profile?.email == "user@example.com") + } + + fun `test selecting personal account emits switching overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + profile = ProfileDto( + email = "user@example.com", + currentOrgId = "org_1", + organizations = listOf(ProfileOrganizationDto("org_1", "Kilo", "OWNER")), + ), + ) + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.selectOrganization(null) } + flush() + + val show = events.filterIsInstance() + .first { it.account.switching } + assertTrue(show.account.switching) + assertNull(show.account.targetOrgId) + assertEquals(null, appRpc.orgSelections.last()) + } + + fun `test replay includes current overlay event`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + flush() + + // Add a new listener after initial events are done + val replayed = collect(m) + + assertTrue(replayed.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test overlay hide event has correct string`() { + assertEquals("AccountOverlayChanged hide", SessionControllerEvent.AccountOverlayChanged.Hide.toString()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt index 96f63afe9b5..8d33870f7f3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt @@ -19,6 +19,7 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" + AccountOverlayChanged show loggedIn=false ViewChanged recents=0 WorkspaceChanged WorkspaceReady diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index b11df3d3320..cc5b1130da0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Font @@ -37,7 +38,7 @@ class SessionEditorStyleTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } - fun `test custom style derives fonts from supplied editor baseline`() { + fun `test custom style keeps editor fields from supplied baseline`() { val style = SessionEditorStyle.create(family = "Courier New", size = 22) assertEquals("Courier New", style.editorFamily) @@ -48,6 +49,43 @@ class SessionEditorStyleTest : BasePlatformTestCase() { assertEquals(22, style.boldEditorFont.size) assertTrue(style.boldEditorFont.isBold) assertTrue(style.smallEditorFont.size < style.editorSize) - assertEquals(style.editorSize, style.uiFont.size) + } + + // --- UI fonts come from UiStyle.Fonts, NOT from the editor --- + + fun `test headerFont equals UiStyle Fonts header`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + assertEquals(UiStyle.Fonts.header(), style.headerFont) + } + + fun `test hintFont equals UiStyle Fonts hint`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + assertEquals(UiStyle.Fonts.hint(), style.hintFont) + } + + fun `test regularFont equals UiStyle Fonts regular`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + assertEquals(UiStyle.Fonts.regular(), style.regularFont) + } + + fun `test boldFont equals UiStyle Fonts bold`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + assertEquals(UiStyle.Fonts.bold(), style.boldFont) + assertTrue(style.boldFont.isBold) + } + + fun `test smallFont equals UiStyle Fonts small`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + assertEquals(UiStyle.Fonts.small(), style.smallFont) + } + + fun `test ui fonts do not use editor font family`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + + assertFalse("headerFont should not use editor font family", style.headerFont.name == "Courier New") + assertFalse("hintFont should not use editor font family", style.hintFont.name == "Courier New") + assertFalse("regularFont should not use editor font family", style.regularFont.name == "Courier New") + assertFalse("boldFont should not use editor font family", style.boldFont.name == "Courier New") + assertFalse("smallFont should not use editor font family", style.smallFont.name == "Courier New") } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 79541206e87..45959beeb29 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -9,7 +9,8 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.views.PermissionView +import ai.kilocode.client.session.views.LoginRequiredView +import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TextView @@ -324,6 +325,52 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertSame(item.progress, item.components.last()) } + fun `test login required state makes LoginRequiredView visible and hides others`() { + val item = panelWithPrompts() + model.setState(SessionState.LoginRequired("Sign in required.")) + + val lv = find(item)!! + val qv = find(item)!! + val pv = find(item)!! + + assertTrue(lv.isVisible) + assertFalse(qv.isVisible) + assertFalse(pv.isVisible) + assertSame(item.progress, item.components.last()) + } + + fun `test login required is anchored before progress footer`() { + val item = panelWithPrompts() + model.setState(SessionState.LoginRequired("Sign in required.")) + + val lv = find(item)!! + val comps = item.components.toList() + + assertTrue(comps.indexOf(lv) < comps.indexOf(item.progress)) + assertSame(item.progress, comps.last()) + } + + fun `test returning to idle hides login required view`() { + val item = panelWithPrompts() + model.setState(SessionState.LoginRequired("Sign in required.")) + model.setState(SessionState.Idle) + + val lv = find(item)!! + + assertFalse(lv.isVisible) + assertSame(item.progress, item.components.last()) + } + + fun `test login required button invokes openProfile callback`() { + var called = false + val lv = LoginRequiredView(openProfile = { called = true }, dismiss = {}) + lv.show("Sign in required.") + + lv.openProfileButton().doClick() + + assertTrue(called) + } + // ------ question tool suppression ------ fun `test active linked question hides matching running question tool`() { @@ -406,13 +453,15 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private fun panelWithPrompts(): SessionMessageListPanel { val q = QuestionView( + project = project, reply = { _, _ -> }, reject = { _ -> }, ) val p = PermissionView( reply = { _, _ -> }, ) - return SessionMessageListPanel(model, parent, q, p) + val l = LoginRequiredView(openProfile = {}, dismiss = {}) + return SessionMessageListPanel(model, parent, q, p, l) } private inline fun find(root: Container): T? = findCls(root, T::class.java) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 375ae454afb..161fa553118 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -125,8 +125,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val mv = panel.findMessage("a1")!! val gv = mv.part("g1") assertNotNull(gv) - assertTrue(gv is ai.kilocode.client.session.views.GenericView) - assertTrue((gv as ai.kilocode.client.session.views.GenericView).labelText().contains("snapshot")) + assertTrue(gv is ai.kilocode.client.session.views.base.GenericView) + assertTrue((gv as ai.kilocode.client.session.views.base.GenericView).labelText().contains("snapshot")) } // ------ silent part types ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt new file mode 100644 index 00000000000..f28fd218cfa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -0,0 +1,386 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.controller.SessionControllerEvent.AccountOverlaySnapshot +import ai.kilocode.client.session.controller.SessionControllerTestBase +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileBalanceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileOrganizationDto +import com.intellij.icons.AllIcons + +@Suppress("UnstableApiUsage") +class SessionAccountOverlayTest : SessionControllerTestBase() { + + private lateinit var panel: SessionAccountOverlay + private var profileCalls = 0 + + override fun setUp() { + super.setUp() + panel = SessionAccountOverlay( + select = { }, + profile = { profileCalls++ }, + ) + } + + private fun show(snap: AccountOverlaySnapshot) { + edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap)) } + } + + private fun hide() { + edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Hide) } + } + + private fun snap(profile: ProfileDto?) = + AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = profile) + + private fun profile( + email: String = "user@example.com", + name: String? = null, + organizations: List = emptyList(), + balance: ProfileBalanceDto? = null, + currentOrgId: String? = null, + ) = ProfileDto( + email = email, + name = name, + organizations = organizations, + balance = balance, + currentOrgId = currentOrgId, + ) + + private fun org(id: String, name: String, role: String = "MEMBER") = + ProfileOrganizationDto(id = id, name = name, role = role) + + // --- test 1: logged-out state hides the overlay entirely --- + + fun `test logged out state hides overlay`() { + show(snap(null)) + edt { assertFalse(panel.isVisible) } + } + + // --- test 2: logged-in personal account shows picker title --- + + fun `test logged in personal account shows picker title`() { + val prof = profile( + email = "user@example.com", + name = "Test User", + balance = ProfileBalanceDto(10.0), + ) + show(snap(prof)) + edt { + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + assertTrue(panel.pickerVisible()) + assertEquals("Personal Account", panel.accountTitle()) + } + } + + fun `test logged in with email fallback still shows personal account title`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + edt { assertEquals("Personal Account", panel.accountTitle()) } + } + + // --- test 3: logged-in org account shows org title in picker --- + + fun `test logged in org account shows org title in picker`() { + val acme = org("org_1", "Acme", "MEMBER") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + balance = ProfileBalanceDto(25.0), + currentOrgId = "org_1", + ) + show(snap(prof)) + edt { + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + assertTrue(panel.pickerVisible()) + assertEquals("Acme", panel.accountTitle()) + // personal + acme = 2 choices + assertEquals(2, panel.choiceCount()) + // selected index is 1 (org_1 is the second item) + assertEquals(1, panel.selectedIndex()) + } + } + + // --- test 4: programmatic update does not call select callback --- + + fun `test programmatic update does not call select callback`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + selected.clear() + + // Show again with same profile - no user selection + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + assertEquals(0, selected.size) + } + + // --- test 5: switching disables picker --- + + fun `test switching true disables picker`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = "org_1", + ) + show(switchingSnap) + edt { assertFalse(panel.pickerEnabled()) } + } + + fun `test switching false enables picker`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + show(snap(prof)) + edt { assertTrue(panel.pickerEnabled()) } + } + + // --- test 6: switching with targetOrgId shows the target account title --- + + fun `test switching with targetOrgId shows target account title`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = "org_1", + ) + show(switchingSnap) + edt { + assertEquals("Acme", panel.accountTitle()) + assertFalse(panel.pickerEnabled()) + } + } + + fun `test switching to personal account shows personal account title`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = "org_1", + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = null, + ) + show(switchingSnap) + edt { + assertEquals("Personal Account", panel.accountTitle()) + assertFalse(panel.pickerEnabled()) + } + } + + fun `test account switcher uses card background and border`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + edt { + assertEquals(UiStyle.Colors.cardBg(), panel.panelBackground()) + assertEquals(UiStyle.Colors.cardBorder(), panel.panelBorderColor()) + } + } + + // --- test 7: transient null profile keeps existing logged-in content --- + + fun `test transient null profile keeps logged in card`() { + val prof = profile(email = "user@example.com", name = "Test User") + show(snap(prof)) + edt { + assertTrue(panel.loggedInVisible()) + assertEquals("Personal Account", panel.accountTitle()) + } + + // Show transient null (pending switch) + val transientSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = null, + transient = true, + ) + show(transientSnap) + edt { + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + } + } + + // --- test 8: hide event hides component --- + + fun `test hide event hides component`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + edt { assertTrue(panel.isVisible) } + + hide() + edt { assertFalse(panel.isVisible) } + } + + // --- test 9: renderer uses check icon for active account --- + + fun `test renderer active account uses check icon`() { + val choice = AccountChoice("org_1", "Acme") + val renderer = AccountPickerRenderer { "org_1" } + + assertSame(AccountPickerRenderer.checked, renderer.icon(choice)) + } + + // --- test 10: renderer uses empty icon for inactive account --- + + fun `test renderer inactive account reserves icon space`() { + val choice = AccountChoice(null, "Personal Account") + val renderer = AccountPickerRenderer { "org_1" } + + assertSame(AccountPickerRenderer.empty, renderer.icon(choice)) + assertEquals(AllIcons.Actions.Checked.iconWidth, renderer.icon(choice).iconWidth) + } + + // --- test 11: balance badge appears when profile has balance --- + + fun `test logged in account shows balance badge`() { + val prof = profile(balance = ProfileBalanceDto(10.0)) + show(snap(prof)) + edt { + assertTrue(panel.balanceVisible()) + assertTrue(panel.balanceIcon() is FilledBadgeIcon) + assertEquals("\$10.00", panel.balanceText()) + } + } + + // --- test 12: balance badge hides when balance is missing --- + + fun `test logged in account hides balance badge without balance`() { + show(snap(profile(balance = null))) + edt { + assertFalse(panel.balanceVisible()) + assertNull(panel.balanceIcon()) + } + } + + // --- test 13: balance badge updates when profile balance changes --- + + fun `test balance badge updates retained label`() { + show(snap(profile(balance = ProfileBalanceDto(10.0)))) + edt { assertEquals("\$10.00", panel.balanceText()) } + + show(snap(profile(balance = ProfileBalanceDto(25.0)))) + edt { + assertTrue(panel.balanceVisible()) + assertEquals("\$25.00", panel.balanceText()) + } + } + + // --- test 14: profile button uses toolbar icon and invokes callback --- + + fun `test profile button uses profile icon and opens settings`() { + show(snap(profile(email = "user@example.com"))) + edt { + assertSame(AllIcons.General.User, panel.profileIcon()) + panel.clickProfile() + } + assertEquals(1, profileCalls) + } + + // --- test 15: transient null profile keeps logged in balance badge --- + + fun `test transient null profile keeps logged in balance badge`() { + show(snap(profile(balance = ProfileBalanceDto(10.0)))) + // Capture icon on EDT + var icon: javax.swing.Icon? = null + edt { icon = panel.balanceIcon() } + + show(AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = null, transient = true)) + edt { + assertTrue(panel.loggedInVisible()) + assertTrue(panel.balanceVisible()) + assertSame(icon, panel.balanceIcon()) + } + } + + // --- test 16: non-transient null profile after login hides overlay --- + + fun `test non-transient null profile after login hides overlay`() { + show(snap(profile(email = "user@example.com"))) + edt { assertTrue(panel.isVisible) } + + show(snap(null)) + edt { assertFalse(panel.isVisible) } + } + + // --- test 17: account choice activation selects different org --- + + fun `test activate different org calls select callback`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile(organizations = listOf(acme), currentOrgId = null) + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + // Simulate selecting org_1 (different from currentOrgId = null) + edt { p.activate(AccountChoice("org_1", "Acme")) } + + assertEquals(listOf("org_1"), selected) + } + + fun `test activate personal calls select with null`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile(organizations = listOf(acme), currentOrgId = "org_1") + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + edt { p.activate(AccountChoice(null, "Personal Account")) } + + assertEquals(listOf(null), selected) + } + + fun `test activate same account does not call select callback`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile(organizations = listOf(acme), currentOrgId = "org_1") + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + // Activating the currently active org should not fire select + edt { p.activate(AccountChoice("org_1", "Acme")) } + + assertEquals(0, selected.size) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 217c8ca94d2..361d1846e1c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -54,7 +54,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val style = SessionEditorStyle.current() assertTrue(panel.isVisible) - assertTrue(panel.isExpanded()) + assertFalse(panel.isExpanded()) assertEquals("Generated title", panel.titleText()) assertEquals("$0.07", panel.costText()) assertEquals("1%", panel.contextText()) @@ -135,6 +135,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val timeline = panel.timelinePanel() val bar = panel.contextBar() + assertFalse(panel.isExpanded()) + panel.expandButton().doClick() + assertTrue(panel.isExpanded()) assertSame(body, panel.bodyPanel()) assertSame(timeline, panel.timelinePanel()) @@ -244,26 +247,27 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - assertTrue(panel.isExpanded()) - assertEquals("Hide session metrics", panel.expandTip()) - - panel.expandButton().doClick() - emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) - assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) panel.expandButton().doClick() - emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) assertTrue(panel.isExpanded()) assertEquals("Hide session metrics", panel.expandTip()) + + panel.expandButton().doClick() + emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) + + assertFalse(panel.isExpanded()) + assertEquals("Show session metrics", panel.expandTip()) } fun `test collapse persists and new header starts collapsed`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) + panel.expandButton().doClick() panel.expandButton().doClick() assertFalse(panel.isExpanded()) @@ -294,6 +298,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { } fun `test hidden empty header collapse keeps saved expansion preference`() { + PropertiesComponent.getInstance().setValue(SessionHeaderPanel.EXPANDED_KEY, "true") appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val c = controller() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt new file mode 100644 index 00000000000..63aa4cbaa87 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -0,0 +1,206 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextArea +import java.awt.Container + +@Suppress("UnstableApiUsage") +class LoginRequiredViewTest : BasePlatformTestCase() { + + // ------ title and message rendering ------ + + fun `test header title text is in the component tree`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val title = findAll(view).firstOrNull { it.text.isNotEmpty() && it.font.isBold } + assertNotNull("Header title text area should be present", title) + } + } + + fun `test description message text is in the component tree after show`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val desc = findAll(view).firstOrNull { it.text == "Sign in required." } + assertNotNull("Description text area should contain the show message", desc) + } + } + + fun `test show updates description without recreating title`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("First message.") + val before = findAll(view).firstOrNull { it.text == "First message." } + assertNotNull(before) + + view.show("Second message.") + val after = findAll(view).firstOrNull { it.text == "Second message." } + assertNotNull("Description should update to second message", after) + val stale = findAll(view).firstOrNull { it.text == "First message." } + assertNull("Old description text should not remain", stale) + } + } + + // ------ open profile button style ------ + + fun `test open profile button is primary`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.openProfileButton() + assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + } + } + + fun `test open profile button uses question surface background`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.openProfileButton() + assertEquals(SessionUiStyle.View.surface(), btn.background) + } + } + + // ------ dismiss button style ------ + + fun `test dismiss button does not have default style key`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.dismissButton() + val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) + assertTrue("Dismiss should not be primary", key == null || key == false) + } + } + + fun `test login action buttons share right-aligned footer group`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + + val dismiss = view.dismissButton() + val open = view.openProfileButton() + assertSame("Dismiss and open profile should be in the same right-aligned group", dismiss.parent, open.parent) + assertTrue("Dismiss should appear before open profile", dismiss.parent.components.indexOf(dismiss) < open.parent.components.indexOf(open)) + } + } + + // ------ callbacks ------ + + fun `test open profile button click invokes openProfile callback`() { + var called = false + edt { + val view = LoginRequiredView(openProfile = { called = true }, dismiss = {}) + view.show("Sign in required.") + view.openProfileButton().doClick() + } + assertTrue("openProfile should have been called", called) + } + + fun `test dismiss button click invokes dismiss callback`() { + var called = false + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = { called = true }) + view.show("Sign in required.") + view.dismissButton().doClick() + } + assertTrue("dismiss should have been called", called) + } + + // ------ visibility ------ + + fun `test view is initially hidden`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + assertFalse(view.isVisible) + } + } + + fun `test show makes view visible`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + assertTrue(view.isVisible) + } + } + + fun `test hideView makes view invisible`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + view.hideView() + assertFalse(view.isVisible) + } + } + + fun `test hideView is idempotent when already hidden`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.hideView() + assertFalse(view.isVisible) + } + } + + // ------ fonts: standard UI family, not editor ------ + + fun `test header uses headerFont not editor font family`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val style = SessionEditorStyle.create(family = "Courier New", size = 20) + view.applyStyle(style) + + val title = findAll(view).firstOrNull { it.font.isBold } + assertNotNull("Bold title text area should be present", title) + assertFalse( + "Title font should not use editor font family", + title!!.font.name == "Courier New", + ) + assertEquals("Title font should equal headerFont", style.headerFont, title.font) + } + } + + fun `test description uses hintFont not editor font family`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val style = SessionEditorStyle.create(family = "Courier New", size = 20) + view.applyStyle(style) + + val desc = findAll(view).firstOrNull { it.text == "Sign in required." } + assertNotNull("Description text area should be present", desc) + assertFalse( + "Description font should not use editor font family", + desc!!.font.name == "Courier New", + ) + assertEquals("Description font should equal hintFont", style.hintFont, desc.font) + } + } + + // ------ helpers ------ + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private inline fun findAll(root: Container): List = + findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (cls.isInstance(child)) result.add(cls.cast(child)) + if (child is Container) result.addAll(findAllCls(child, cls)) + } + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt deleted file mode 100644 index 73dca0fc954..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package ai.kilocode.client.session.views - -import ai.kilocode.client.session.model.Permission -import ai.kilocode.client.session.model.PermissionMeta -import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import java.awt.Container -import javax.swing.AbstractButton - -@Suppress("UnstableApiUsage") -class PermissionViewTest : BasePlatformTestCase() { - - private val replies = mutableListOf>() - private lateinit var view: PermissionView - - override fun setUp() { - super.setUp() - view = PermissionView( - reply = { id, dto -> replies.add(id to dto) }, - ) - } - - fun `test allow button uses bundle text and replies once`() { - view.show(permission()) - - buttons(view).first { it.text == "Allow" }.doClick() - - assertFalse(view.isVisible) - assertEquals(1, replies.size) - assertEquals("perm1", replies.single().first) - assertEquals("once", replies.single().second.reply) - } - - fun `test deny button uses bundle text and rejects`() { - view.show(permission()) - - buttons(view).first { it.text == "Deny" }.doClick() - - assertFalse(view.isVisible) - assertEquals(1, replies.size) - assertEquals("perm1", replies.single().first) - assertEquals("reject", replies.single().second.reply) - } - - fun `test blank patterns display star`() { - view.show( - Permission( - id = "perm2", - sessionId = "ses", - name = "edit", - patterns = emptyList(), - always = emptyList(), - meta = PermissionMeta(), - ) - ) - - assertTrue(view.isVisible) - } - - private fun permission() = Permission( - id = "perm1", - sessionId = "ses_test", - name = "edit", - patterns = listOf("*.kt"), - always = emptyList(), - meta = PermissionMeta(), - message = "Review file changes", - ) - - private fun buttons(root: Container): List = root.components.flatMap { comp -> - val item = if (comp is AbstractButton) listOf(comp) else emptyList() - if (comp is Container) item + buttons(comp) else item - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 9038cbffb3e..8d03559f920 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -166,7 +166,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { // ------ applyStyle ------ - fun `test applyStyle updates fonts`() { + fun `test applyStyle updates body fonts to UI font family`() { val tool = completedTool( input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), @@ -177,8 +177,25 @@ class QuestionResultViewTest : BasePlatformTestCase() { view.applyStyle(style) view.toggle() - assertTrue(view.bodyFonts().contains(style.transcriptFont)) - assertTrue(view.bodyFonts().contains(style.boldEditorFont)) + assertTrue(view.bodyFonts().contains(style.regularFont)) + assertTrue(view.bodyFonts().contains(style.boldFont)) + assertFalse("Body should not use editor transcript font", view.bodyFonts().any { it.name == "Courier New" }) + } + + fun `test applyStyle updates header label fonts to UI font family`() { + val tool = completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + ) + val view = QuestionResultView(tool) + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + + view.applyStyle(style) + + assertEquals("Title should use boldFont", style.boldFont, view.titleFont()) + assertEquals("Subtitle should use smallFont", style.smallFont, view.subFont()) + assertFalse("Title should not use editor font family", view.titleFont().name == "Courier New") + assertFalse("Subtitle should not use editor font family", view.subFont().name == "Courier New") } // ------ update ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index f1bc5c8b702..b35c7e1538b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -3,19 +3,24 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.ui.HoverIcon import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import java.awt.Component import java.awt.Container +import kotlin.math.abs import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class QuestionViewTest : BasePlatformTestCase() { @@ -28,6 +33,7 @@ class QuestionViewTest : BasePlatformTestCase() { override fun setUp() { super.setUp() view = QuestionView( + project = project, reply = { id, dto -> replies.add(id to dto) }, reject = { id -> rejects.add(id) }, scroll = { scrolls++ }, @@ -85,6 +91,31 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(replies.isEmpty()) } + fun `test question action buttons share right-aligned footer group`() { + view.show(singleSelectQuestion("req_actions")) + + val dismiss = button(view, "Dismiss") + val submit = button(view, "Submit") + assertSame("Dismiss and Submit should be in the same right-aligned group", dismiss.parent, submit.parent) + assertTrue("Dismiss should appear before Submit", dismiss.parent.components.indexOf(dismiss) < submit.parent.components.indexOf(submit)) + } + + fun `test review action buttons share right-aligned footer group`() { + view.show(twoItemQuestion("req_review_actions")) + option(view, "Minimal").doClick() + button(view, "Next").doClick() + option(view, "Unit").doClick() + button(view, "Review").doClick() + + val dismiss = button(view, "Dismiss") + val back = button(view, "Back") + val submit = button(view, "Submit") + assertSame("Dismiss and Back should be in the same right-aligned group", dismiss.parent, back.parent) + assertSame("Back and Submit should be in the same right-aligned group", back.parent, submit.parent) + assertTrue("Dismiss should appear before Back", dismiss.parent.components.indexOf(dismiss) < back.parent.components.indexOf(back)) + assertTrue("Back should appear before Submit", back.parent.components.indexOf(back) < submit.parent.components.indexOf(submit)) + } + // ------ radio options ------ fun `test single question renders radio options`() { @@ -157,19 +188,64 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals("description should align in the text renderer", label.parent, desc.parent) val style = SessionEditorStyle.current() - assertEquals("option label should use bold editor font", style.boldEditorFont, label.font) - assertEquals("description should use transcript font", style.transcriptFont, desc.font) + assertEquals("option label should use boldFont", style.boldFont, label.font) + assertEquals("description should use regularFont", style.regularFont, desc.font) } - fun `test question title and hint use editor fonts`() { + fun `test option row without description centers button beside label`() { + view.show( + Question( + id = "no_desc_center", + items = listOf( + QuestionItem( + question = "Pick one", + header = "Pick", + options = listOf(QuestionOption("Plain", "")), + multiple = false, + custom = false, + ) + ), + ) + ) + layout(view) + + val radio = option(view, "Plain") + val label = text(view, "Plain") + val row = label.parent.parent as Container + + val radioCenter = center(radio, row) + val labelCenter = center(label, row) + assertTrue( + "radio should be vertically centered with a single-line label: radio=$radioCenter label=$labelCenter row=${row.size}", + abs(radioCenter - labelCenter) <= 2, + ) + } + + fun `test custom row centers button beside label`() { + view.show(customSingleQuestion("custom_center")) + layout(view) + + val radio = findAll(view).first { it.actionCommand == "" } + val label = text(view, "Add your own response") + val row = label.parent.parent as Container + + val radioCenter = center(radio, row) + val labelCenter = center(label, row) + assertTrue( + "custom radio should be vertically centered with the label: radio=$radioCenter label=$labelCenter row=${row.size}", + abs(radioCenter - labelCenter) <= 2, + ) + } + + fun `test question title uses headerFont and hint uses hintFont`() { view.show(singleSelectQuestion("q_fonts")) val style = SessionEditorStyle.current() val title = text(view, "Choose approach") val hint = text(view, "Select one answer") - assertEquals(style.boldEditorFont, title.font) - assertEquals(style.transcriptFont, hint.font) + assertEquals("title should use headerFont", style.headerFont, title.font) + assertEquals("hint should use hintFont", style.hintFont, hint.font) } // ------ multi-question navigation ------ @@ -364,6 +440,66 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) } + fun `test submit has DarculaButtonUI default style key`() { + view.show(singleSelectQuestion("q_btn_type")) + + val submit = button(view, "Submit") + assertEquals("Submit should be primary (default style key)", true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + } + + fun `test dismiss does not have default style key`() { + view.show(singleSelectQuestion("q_dismiss_type")) + + val dismiss = button(view, "Dismiss") + val key = dismiss.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) + assertTrue("Dismiss should not be primary", key == null || key == false) + } + + fun `test session question buttons use question surface background`() { + view.show(singleSelectQuestion("q_btn_bg")) + + val dismiss = button(view, "Dismiss") + val submit = button(view, "Submit") + + assertEquals(SessionUiStyle.View.surface(), dismiss.background) + assertEquals(SessionUiStyle.View.surface(), submit.background) + } + + fun `test review submit and back buttons have correct primary state on review page`() { + view.show(twoItemQuestion("q_review_types")) + + option(view, "Minimal").doClick() + button(view, "Next").doClick() + option(view, "Unit").doClick() + button(view, "Review").doClick() + + val submit = button(view, "Submit") + val back = button(view, "Back") + + assertEquals("Submit on review page should be primary", true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + val backKey = back.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) + assertTrue("Back on review page should not be primary", backKey == null || backKey == false) + } + + fun `test next button is not primary before last item`() { + view.show(twoItemQuestion("q_next_not_primary")) + + val next = button(view, "Next") + + val key = next.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) + assertTrue("Next should not be primary on first question", key == null || key == false) + } + + fun `test review button is primary on last item`() { + view.show(twoItemQuestion("q_review_primary")) + option(view, "Minimal").doClick() + button(view, "Next").doClick() + + val review = button(view, "Review") + + assertEquals("Review should be primary on last question", true, review.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + } + fun `test single question hides header nav`() { view.show(singleSelectQuestion("q_single")) @@ -428,6 +564,300 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(listOf(listOf("A")), replies.single().second.answers) } + // ------ custom question row ------ + + fun `test custom row renders when custom is true`() { + view.show(customSingleQuestion("q_custom_present")) + + assertLabelsContain(view, "Add your own response") + } + + fun `test custom row is absent when custom is false`() { + view.show(singleSelectQuestion("q_custom_absent")) + + assertLabelsDoNotContain(view, "Add your own response") + } + + fun `test custom single select answer submits as typed text`() { + view.show(customSingleQuestion("q_custom_submit")) + + // Click the custom radio button (actionCommand is "") + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + + // Find the editor that appeared and type text + val ed = findAll(view).first() + ed.text = "my custom answer" + + button(view, "Submit").doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers) + } + + fun `test custom editor grows for wrapped input`() { + view.show(customSingleQuestion("q_custom_grow")) + layout(view, 240) + + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + layout(view, 240) + + val ed = findAll(view).first() + val initial = ed.preferredSize.height + ed.text = "wrapped ".repeat(30) + + assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial) + } + + fun `test blank custom input does not enable submit`() { + view.show(customSingleQuestion("q_custom_blank")) + + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + + val submit = button(view, "Submit") + assertFalse("Submit should remain disabled when custom text is blank", submit.isEnabled) + } + + fun `test selecting normal option after custom input sends option not custom text`() { + view.show(customSingleQuestion("q_custom_revert")) + + // Open custom and type something + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + val ed = findAll(view).first() + ed.text = "stale custom" + + // Now select a normal option + option(view, "Minimal").doClick() + + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + } + + fun `test selecting normal option after custom input clears custom radio selection`() { + view.show(customSingleQuestion("q_custom_clear_radio")) + + val radio = findAll(view).first { it.actionCommand == "" } + radio.doClick() + val ed = findAll(view).first() + ed.text = "stale custom" + + option(view, "Minimal").doClick() + + val custom = findAll(view).first { it.actionCommand == "" } + assertFalse("Custom radio should not stay selected after choosing a normal option", custom.isSelected) + assertTrue("Normal option should be selected", option(view, "Minimal").isSelected) + assertTrue("Custom editor should stay visible for non-empty text", findAll(view).any { it.parent != null && it.text == "stale custom" }) + assertLabelsDoNotContain(view, "stale custom") + } + + fun `test empty custom editor is removed after selecting normal option`() { + view.show(customSingleQuestion("q_custom_empty_editor")) + + findAll(view).first { it.actionCommand == "" }.doClick() + assertNotNull(findAll(view).firstOrNull { it.parent != null }) + + option(view, "Minimal").doClick() + + assertNull("Empty custom editor should be removed after selecting a normal option", findAll(view).firstOrNull { it.parent != null }) + } + + fun `test focusing retained custom editor reselects custom response`() { + view.show(customSingleQuestion("q_custom_focus")) + + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = "stale custom" + option(view, "Minimal").doClick() + + view.testFocusCustomEditor() + + assertTrue("Custom radio should be selected when its editor takes focus", findAll(view).first { it.actionCommand == "" }.isSelected) + assertFalse("Normal option should be cleared when custom editor takes focus", option(view, "Minimal").isSelected) + assertEquals("Submit should send custom text after focusing retained editor", listOf(listOf("stale custom")), run { + button(view, "Submit").doClick() + replies.single().second.answers + }) + } + + fun `test multi select custom answer combines with selected options`() { + view.show(customMultiQuestion("q_multi_custom")) + + option(view, "A").doClick() + val customBox = findAll(view).first { it.actionCommand == "" } + customBox.doClick() + val ed = findAll(view).first() + ed.text = "extra" + + button(view, "Review").doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("A", "extra")), replies.single().second.answers) + } + + fun `test custom input is trimmed before submit`() { + view.show(customSingleQuestion("q_custom_trim")) + + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = " trimmed answer " + + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("trimmed answer")), replies.single().second.answers) + } + + fun `test multi select custom answer can be unchecked`() { + view.show(customMultiQuestion("q_multi_custom_unchecked")) + + option(view, "A").doClick() + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = "extra" + + findAll(view).first { it.actionCommand == "" }.doClick() + + assertFalse( + "Custom checkbox should be unchecked", + findAll(view).first { it.actionCommand == "" }.isSelected, + ) + assertTrue("Review should stay enabled because a normal option is selected", button(view, "Review").isEnabled) + button(view, "Review").doClick() + assertLabelsContain(view, "A") + assertLabelsDoNotContain(view, "extra") + + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("A")), replies.single().second.answers) + } + + fun `test duplicate custom answer is submitted once`() { + view.show(customMultiQuestion("q_multi_custom_duplicate")) + + option(view, "A").doClick() + findAll(view).first { it.actionCommand == "" }.doClick() + findAll(view).first().text = "A" + + button(view, "Review").doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("A")), replies.single().second.answers) + } + + fun `test custom text appears in review`() { + view.show( + Question( + id = "q_custom_review", + items = listOf( + QuestionItem( + question = "How?", + header = "H", + options = listOf(QuestionOption("X", "")), + multiple = false, + custom = true, + ), + QuestionItem( + question = "What?", + header = "W", + options = listOf(QuestionOption("Y", "")), + multiple = false, + custom = false, + ), + ), + ) + ) + + // Answer first with custom + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + val ed = findAll(view).first() + ed.text = "typed answer" + + button(view, "Next").doClick() + option(view, "Y").doClick() + button(view, "Review").doClick() + + assertLabelsContain(view, "typed answer") + } + + fun `test custom text preserved across navigation`() { + view.show( + Question( + id = "q_custom_nav", + items = listOf( + QuestionItem( + question = "How?", + header = "H", + options = listOf(QuestionOption("X", "")), + multiple = false, + custom = true, + ), + QuestionItem( + question = "What?", + header = "W", + options = listOf(QuestionOption("Y", "")), + multiple = false, + custom = false, + ), + ), + ) + ) + + // Open custom on first question and type + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + val ed = findAll(view).first() + ed.text = "preserved text" + + // Navigate forward + button(view, "Next").doClick() + option(view, "Y").doClick() + + // Navigate back + navButton(view, "Back").doClick() + + // Custom row should still be open with the preserved text in the editor + val editorAfterBack = findAll(view).firstOrNull() + assertNotNull("Custom editor should still be visible after navigating back", editorAfterBack) + assertEquals("Custom editor should have preserved text", "preserved text", editorAfterBack!!.text) + } + + fun `test optionless custom question is answerable`() { + view.show( + Question( + id = "q_optionless", + items = listOf( + QuestionItem( + question = "Free answer", + header = "Free", + options = emptyList(), + multiple = false, + custom = true, + ) + ), + ) + ) + + // The custom row should be present + assertLabelsContain(view, "Add your own response") + + // Open the custom row + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + + val ed = findAll(view).first() + ed.text = "my answer" + + val submit = button(view, "Submit") + assertTrue("Submit should be enabled after typing in optionless custom question", submit.isEnabled) + + submit.doClick() + + assertFalse(view.isVisible) + assertEquals(listOf(listOf("my answer")), replies.single().second.answers) + } + // ------ helpers ------ /** @@ -447,6 +877,21 @@ class QuestionViewTest : BasePlatformTestCase() { private fun text(root: Container, value: String): JBTextArea = findAll(root).first { it.text == value } + private fun layout(root: Container, width: Int = 400) { + root.setSize(width, root.preferredSize.height) + layoutTree(root) + } + + private fun layoutTree(root: Container) { + root.doLayout() + for (child in root.components) { + if (child is Container) layoutTree(child) + } + } + + private fun center(component: Component, root: Component): Int = + SwingUtilities.convertPoint(component, 0, component.height / 2, root).y + private fun singleSelectQuestion(id: String) = Question( id = id, items = listOf( @@ -489,6 +934,35 @@ class QuestionViewTest : BasePlatformTestCase() { ), ) + private fun customSingleQuestion(id: String) = Question( + id = id, + items = listOf( + QuestionItem( + question = "Choose approach", + header = "Approach", + options = listOf( + QuestionOption("Minimal", "Smallest safe change"), + QuestionOption("Balanced", "Focused implementation"), + ), + multiple = false, + custom = true, + ) + ), + ) + + private fun customMultiQuestion(id: String) = Question( + id = id, + items = listOf( + QuestionItem( + question = "Pick features", + header = "Features", + options = listOf(QuestionOption("A", ""), QuestionOption("B", "")), + multiple = true, + custom = true, + ) + ), + ) + private fun assertLabelsContain(root: Container, text: String) { val found = findAll(root).any { it.text == text } || findAll(root).any { it.text == text } assertTrue("Expected label '$text' to be present", found) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index 6cae7834def..f0cf4d20169 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -8,61 +8,58 @@ import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class ReasoningViewTest : BasePlatformTestCase() { - fun `test completed reasoning is expanded by default`() { + fun `test completed reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertEquals("Reasoning", view.headerText()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) assertTrue(view.hasToggle()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) assertTrue(view.bodyCreated()) } fun `test short completed reasoning is collapsible`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) view.toggle() - assertFalse(view.isExpanded()) - assertFalse(view.bodyVisible()) + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) assertTrue(view.bodyCreated()) } - fun `test streaming reasoning is expanded by default`() { + fun `test streaming reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) } - fun `test update to done preserves visible reasoning`() { + fun `test update to done preserves collapsed reasoning`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) } fun `test toggle opens and closes reasoning`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + view.toggle() assertTrue(view.isExpanded()) view.toggle() assertFalse(view.isExpanded()) - view.toggle() - assertTrue(view.isExpanded()) } - fun `test collapsed reasoning expands on update`() { + fun `test collapsed reasoning stays collapsed on update`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) - - view.toggle() view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) assertEquals("one\ntwo\nthree", view.markdown()) } @@ -72,39 +69,38 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("ab", view.markdown()) - assertTrue(view.isExpanded()) + assertFalse(view.isExpanded()) } - fun `test blank reasoning expands when delta arrives`() { + fun `test blank reasoning stays collapsed when delta arrives`() { val view = ReasoningView(reasoning("p1", done = false, text = "")) view.appendDelta("b") assertEquals("b", view.markdown()) assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) + assertTrue(view.hasToggle()) } - fun `test collapsed append reattaches eager reasoning body`() { + fun `test collapsed append keeps eager reasoning body detached`() { val view = ReasoningView(reasoning("p1", done = false, text = "a")) - view.toggle() view.appendDelta("b") assertEquals("ab", view.markdown()) assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) } - fun `test collapsed update reattaches eager reasoning body`() { + fun `test collapsed update keeps eager reasoning body detached`() { val view = ReasoningView(reasoning("p1", done = false, text = "a")) - view.toggle() view.update(reasoning("p1", done = false, text = "abc")) assertEquals("abc", view.markdown()) assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) } fun `test reasoning reuses eager markdown body`() { @@ -116,7 +112,7 @@ class ReasoningViewTest : BasePlatformTestCase() { view.toggle() assertSame(component, view.md.component) - assertFalse(view.bodyVisible()) + assertTrue(view.bodyVisible()) } fun `test blank reasoning has no toggle`() { @@ -158,6 +154,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test expanded reasoning body is capped to five rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) + view.toggle() assertEquals(5, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt new file mode 100644 index 00000000000..5312712fe83 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -0,0 +1,381 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea +import java.awt.BorderLayout +import java.awt.Container +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class BaseQuestionViewTest : BasePlatformTestCase() { + + // ------ initial state ------ + + fun `test header and description text areas are in the component tree by default`() { + edt { + val panel = BaseQuestionView() + val areas = findAll(panel) + assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2) + } + } + + fun `test setHeader sets the header text`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("My Title") + val bold = findAll(panel).firstOrNull { it.font.isBold } + assertNotNull("Bold header text area should be present", bold) + assertEquals("My Title", bold!!.text) + } + } + + fun `test setHeader with description shows description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "Hint text") + val desc = findAll(panel).firstOrNull { it.text == "Hint text" } + assertNotNull("Description text area should be present", desc) + } + } + + fun `test setHeader without description hides description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title") + val areas = findAll(panel) + val nonBold = areas.filter { !it.font.isBold } + // description should either be hidden or blank + assertTrue("Non-bold text areas should be hidden or empty", nonBold.all { !it.isVisible || it.text.isBlank() }) + } + } + + fun `test setDescription with blank hides description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "some text") + panel.setDescription("") + val areas = findAll(panel) + val desc = areas.firstOrNull { !it.font.isBold } + assertTrue("Description should be hidden when blank", desc == null || !desc.isVisible) + } + } + + fun `test setDescription with null hides description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "some text") + panel.setDescription(null) + val areas = findAll(panel) + val desc = areas.firstOrNull { !it.font.isBold } + assertTrue("Description should be hidden when null", desc == null || !desc.isVisible) + } + } + + // ------ setTopPanel ------ + + fun `test setTopPanel adds component before header`() { + edt { + val panel = BaseQuestionView() + val top = JLabel("top") + panel.setTopPanel(top) + + val col = findCol(panel)!! + val comps = col.components.toList() + val topIdx = comps.indexOf(top) + // header row is the JPanel containing the header text area + val headerRow = findAll(panel).firstOrNull { it.font.isBold }?.parent as? JPanel + val headerIdx = if (headerRow != null) comps.indexOf(headerRow) else comps.indexOfFirst { it is JPanel } + assertTrue("top should appear before headerText row", topIdx >= 0 && topIdx < headerIdx) + } + } + + fun `test setTopPanel null removes top component`() { + edt { + val panel = BaseQuestionView() + val top = JLabel("top") + panel.setTopPanel(top) + panel.setTopPanel(null) + + assertNull("top should be removed after setTopPanel(null)", find(panel, top)) + } + } + + fun `test setTopPanel replaces previous top without duplicates`() { + edt { + val panel = BaseQuestionView() + val first = JLabel("first") + val second = JLabel("second") + panel.setTopPanel(first) + panel.setTopPanel(second) + + assertNull("first top should be gone after replacement", find(panel, first)) + assertNotNull("second top should be present", find(panel, second)) + } + } + + // ------ setContent ------ + + fun `test setContent adds component after description`() { + edt { + val panel = BaseQuestionView() + val body = JLabel("body") + panel.setContent(body) + assertNotNull("body should be in the tree", find(panel, body)) + } + } + + fun `test setContent null removes content`() { + edt { + val panel = BaseQuestionView() + val body = JLabel("body") + panel.setContent(body) + panel.setContent(null) + assertNull("body should be removed after setContent(null)", find(panel, body)) + } + } + + fun `test setContent replaces previous content without duplicates`() { + edt { + val panel = BaseQuestionView() + val first = JLabel("first body") + val second = JLabel("second body") + panel.setContent(first) + panel.setContent(second) + assertNull("first body should be gone", find(panel, first)) + assertNotNull("second body should be present", find(panel, second)) + } + } + + // ------ setActions ------ + + fun `test setActions renders one button per action`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf( + BaseQuestionView.Action("a", "Cancel", primary = false) {}, + BaseQuestionView.Action("b", "OK", primary = true) {}, + )) + val btns = panel.actionButtonsForTest() + assertEquals(2, btns.size) + assertNotNull(btns["a"]) + assertNotNull(btns["b"]) + assertEquals("Cancel", btns["a"]!!.text) + assertEquals("OK", btns["b"]!!.text) + } + } + + fun `test primary action has DarculaButtonUI default style key`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) + val btn = panel.actionButtonsForTest()["ok"]!! + assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + } + } + + fun `test non-primary action does not have DarculaButtonUI default style key`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("cancel", "Cancel", primary = false) {})) + val btn = panel.actionButtonsForTest()["cancel"]!! + val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) + assertTrue("Non-primary should not have default style key", key == null || key == false) + } + } + + fun `test action button click invokes handler`() { + edt { + var clicked = false + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) { clicked = true })) + panel.actionButtonsForTest()["ok"]!!.doClick() + assertTrue("handler should have been invoked", clicked) + } + } + + fun `test setActionEnabled disables and enables button`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true, enabled = true) {})) + panel.setActionEnabled("ok", false) + assertFalse(panel.actionButtonsForTest()["ok"]!!.isEnabled) + panel.setActionEnabled("ok", true) + assertTrue(panel.actionButtonsForTest()["ok"]!!.isEnabled) + } + } + + fun `test setActions empty removes all action buttons`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) + panel.setActions(emptyList()) + assertTrue("actionButtonsForTest should be empty", panel.actionButtonsForTest().isEmpty()) + } + } + + fun `test action buttons use question card surface background`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf( + BaseQuestionView.Action("a", "A", primary = false) {}, + BaseQuestionView.Action("b", "B", primary = true) {}, + )) + val btns = panel.actionButtonsForTest() + assertEquals(SessionUiStyle.View.surface(), btns["a"]!!.background) + assertEquals(SessionUiStyle.View.surface(), btns["b"]!!.background) + } + } + + // ------ ordering ------ + + fun `test content appears after description in col`() { + edt { + val panel = BaseQuestionView() + val body = JLabel("body") + panel.setContent(body) + val col = findCol(panel)!! + val comps = col.components.toList() + val descIdx = comps.indexOfFirst { it is JBTextArea && !(it).font.isBold } + val bodyIdx = comps.indexOf(body) + assertTrue("body should appear after description", descIdx < bodyIdx) + } + } + + fun `test action footer appears after content`() { + edt { + val panel = BaseQuestionView() + val body = JLabel("body") + panel.setContent(body) + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) + val col = findCol(panel)!! + val comps = col.components.toList() + val bodyIdx = comps.indexOf(body) + val btn = panel.actionButtonsForTest()["ok"]!! + // find the footer panel that contains the button + val footerIdx = comps.indexOfFirst { it is JPanel && find(it, btn) != null } + assertTrue("footer should appear after body", bodyIdx < footerIdx) + } + } + + // ------ header icon ------ + + fun `test setHeaderIcon adds icon to the left side of header row`() { + edt { + val panel = BaseQuestionView() + panel.setHeaderIcon(AllIcons.General.Warning, "warning") + + val labels = findAll(panel).filter { it.icon != null && it.isVisible } + assertEquals("Expected one header icon", 1, labels.size) + assertSame(AllIcons.General.Warning, labels[0].icon) + assertEquals("warning", labels[0].toolTipText) + } + } + + fun `test setHeaderIcon null hides header icon`() { + edt { + val panel = BaseQuestionView() + panel.setHeaderIcon(AllIcons.General.Warning) + panel.setHeaderIcon(null) + + val labels = findAll(panel).filter { it.icon != null && it.isVisible } + assertTrue("Header icon should be hidden after setHeaderIcon(null)", labels.isEmpty()) + } + } + + // ------ applyStyle: UI fonts ---- + + fun `test applyStyle applies headerFont to header and hintFont to description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "Hint") + val style = SessionEditorStyle.current() + panel.applyStyle(style) + + assertEquals("headerText should use headerFont", style.headerFont, panel.headerFont()) + assertEquals("descriptionText should use hintFont", style.hintFont, panel.descriptionFont()) + } + } + + fun `test applyStyle does not apply editor font family to header or description`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "Hint") + val style = SessionEditorStyle.create(family = "Courier New", size = 20) + panel.applyStyle(style) + + assertFalse("headerText should not use editor font family", panel.headerFont().name == "Courier New") + assertFalse("descriptionText should not use editor font family", panel.descriptionFont().name == "Courier New") + } + } + + fun `test description uses same vertical stacking as option descriptions`() { + edt { + val panel = BaseQuestionView() + panel.setHeader("Title", "Hint") + val desc = findAll(panel).firstOrNull { it.text == "Hint" } + assertNotNull(desc) + val ins = desc!!.border.getBorderInsets(desc) + assertEquals("description should not add extra top padding", 0, ins.top) + } + } + + // ------ helpers ------ + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun findCol(panel: BaseQuestionView): JPanel? { + for (child in panel.components) { + if (child is JPanel) return child + } + return null + } + + private fun find(root: Container, target: JComponent): JComponent? { + if (root === target) return target + for (child in root.components) { + if (child === target) return target + if (child is Container) { + val found = find(child, target) + if (found != null) return found + } + } + return null + } + + private fun find(root: JPanel, target: JButton): JButton? { + for (child in root.components) { + if (child === target) return target + if (child is JPanel) { + val found = find(child, target) + if (found != null) return found + } + } + return null + } + + private inline fun findAll(root: Container): List = findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (child is Container) result.addAll(findAllCls(child, cls)) + } + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt new file mode 100644 index 00000000000..8882b3b0c47 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -0,0 +1,547 @@ +package ai.kilocode.client.session.views.permission + +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.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.rpc.dto.PermissionReplyDto +import com.intellij.icons.AllIcons +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import java.awt.Container +import javax.swing.AbstractButton + +@Suppress("UnstableApiUsage") +class PermissionViewTest : BasePlatformTestCase() { + + private val replies = mutableListOf>() + private lateinit var view: PermissionView + + override fun setUp() { + super.setUp() + view = PermissionView( + reply = { id, dto -> replies.add(id to dto) }, + ) + } + + fun `test run button replies once`() { + view.show(permission()) + + view.runButtonForTest().doClick() + + assertEquals(1, replies.size) + assertEquals("perm1", replies.single().first) + assertEquals("once", replies.single().second.reply) + assertFalse(view.runButtonForTest().isEnabled) + assertFalse(view.denyButtonForTest().isEnabled) + } + + fun `test deny button rejects`() { + view.show(permission()) + + view.denyButtonForTest().doClick() + + assertEquals(1, replies.size) + assertEquals("perm1", replies.single().first) + assertEquals("reject", replies.single().second.reply) + } + + fun `test view is visible after show`() { + view.show(permission()) + assertTrue(view.isVisible) + } + + fun `test hideView makes invisible`() { + view.show(permission()) + view.hideView() + assertFalse(view.isVisible) + } + + fun `test blank patterns show only action label with no code fragment`() { + view.show( + Permission( + id = "perm2", + sessionId = "ses", + name = "edit", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + assertTrue(view.isVisible) + val text = allText(view) + assertTrue("Expected tool label in text, got: $text", text.contains("Edit")) + // No code label should be added when there is no target + assertTrue("Expected no code labels for empty patterns", view.codeLabelsForTest().isEmpty()) + } + + fun `test star-only patterns show action label with no code fragment`() { + view.show( + Permission( + id = "perm3", + sessionId = "ses", + name = "read", + patterns = listOf("*"), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + assertTrue(view.isVisible) + val text = allText(view) + assertTrue("Expected Read label in text, got: $text", text.contains("Read")) + assertTrue("Expected no code labels for star-only patterns", view.codeLabelsForTest().isEmpty()) + } + + fun `test bash permission shows action and command on same row`() { + view.show( + Permission( + id = "perm4", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "git status --short"), + ) + ) + + val text = allText(view) + assertTrue("Expected Shell action label in text, got: $text", text.contains("Shell")) + assertTrue("Expected command in text, got: $text", text.contains("git status --short")) + val labels = view.codeLabelsForTest() + assertEquals("Expected exactly one target pane for command", 1, labels.size) + assertTrue("Expected command in target pane, got: ${labels[0].text}", labels[0].text.contains("git status --short")) + } + + fun `test bash permission shows only header and compact detail`() { + view.show( + Permission( + id = "perm4b", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "git status --short"), + message = "Run this command?", + ) + ) + + val text = allText(view) + assertTrue("Expected permission header, got: $text", text.contains("Permission required")) + assertTrue("Expected command in text, got: $text", text.contains("git status --short")) + // State message should not appear for PENDING state + assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?")) + } + + fun `test non-bash patterns show action and path as separate labels`() { + view.show( + Permission( + id = "perm5", + sessionId = "ses", + name = "read", + patterns = listOf("src/App.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + val text = allText(view) + assertTrue("Expected 'Read' in text, got: $text", text.contains("Read")) + assertTrue("Expected path in text, got: $text", text.containsPath("src/App.kt")) + + val labels = view.codeLabelsForTest() + assertEquals("Expected exactly one target pane for the pattern", 1, labels.size) + assertTrue("Expected path in target pane, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt")) + } + + fun `test multiple patterns joined in code label`() { + view.show( + Permission( + id = "perm_multi", + sessionId = "ses", + name = "glob", + patterns = listOf("src/*.kt", "test/*.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + val labels = view.codeLabelsForTest() + assertEquals("Expected one combined code label for multiple patterns", 1, labels.size) + assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("src/*.kt")) + assertTrue("Expected both patterns in label, got: ${labels[0].text}", labels[0].text.contains("test/*.kt")) + } + + fun `test diff preview renders only stat badge without duplicate file path`() { + view.show( + Permission( + id = "perm6", + sessionId = "ses", + name = "edit", + patterns = listOf("src/A.kt"), + always = emptyList(), + meta = PermissionMeta( + fileDiffs = listOf( + PermissionFileDiff( + file = "src/A.kt", + patch = "@@ -1 +1 @@\n-old\n+new", + additions = 1, + deletions = 2, + ) + ), + ), + ) + ) + + val text = allText(view) + assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt")) + assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt")) + // Patch markers should NOT appear — no diff content is shown + assertFalse("Should not render patch content, got: $text", text.contains("@@")) + assertFalse("Should not render old line, got: $text", text.contains("-old")) + assertFalse("Should not render new line, got: $text", text.contains("+new")) + + val diffs = view.diffViewsForTest() + assertEquals("Expected one diff view", 1, diffs.size) + val badge = diffs[0].badgeForTest() + assertEquals("-2", badge.removedLabelForTest().text) + assertEquals("+1", badge.addedLabelForTest().text) + assertNotSame("Removed and added labels should use different colors", badge.removedLabelForTest().foreground, badge.addedLabelForTest().foreground) + } + + fun `test diff preview shows no unavailable fallback text`() { + view.show( + Permission( + id = "perm_no_patch", + sessionId = "ses", + name = "edit", + patterns = listOf("src/A.kt"), + always = emptyList(), + meta = PermissionMeta( + fileDiffs = listOf( + PermissionFileDiff( + file = "src/A.kt", + patch = null, + additions = 3, + deletions = 1, + ) + ), + ), + ) + ) + + val text = allText(view) + assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt")) + assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt")) + // No "unavailable" fallback text expected in new design + assertFalse("Should not render unavailable fallback, got: $text", text.contains("unavailable")) + val badge = view.diffViewsForTest().single().badgeForTest() + assertEquals("-1", badge.removedLabelForTest().text) + assertEquals("+3", badge.addedLabelForTest().text) + } + + fun `test multiple diffs render each file separately`() { + view.show( + Permission( + id = "perm_multi_diff", + sessionId = "ses", + name = "edit", + patterns = listOf("src/A.kt", "src/B.kt"), + always = emptyList(), + meta = PermissionMeta( + fileDiffs = listOf( + PermissionFileDiff( + file = "src/A.kt", + patch = "@@ -1 +1 @@\n-a\n+b", + additions = 1, + deletions = 1, + ), + PermissionFileDiff( + file = "src/B.kt", + patch = "@@ -2 +2 @@\n-c\n+d", + additions = 2, + deletions = 3, + ), + ), + ), + ) + ) + + val diffs = view.diffViewsForTest() + assertEquals("Expected two diff views", 2, diffs.size) + assertEquals("-1", diffs[0].badgeForTest().removedLabelForTest().text) + assertEquals("+1", diffs[0].badgeForTest().addedLabelForTest().text) + assertEquals("-3", diffs[1].badgeForTest().removedLabelForTest().text) + assertEquals("+2", diffs[1].badgeForTest().addedLabelForTest().text) + // Patch content should not be in text + val text = allText(view) + assertFalse("Should not render patch markers, got: $text", text.contains("@@")) + } + + fun `test no rule controls rendered`() { + view.show( + Permission( + id = "perm7", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = listOf("src/**"), + meta = PermissionMeta(rules = listOf("rule1")), + ) + ) + + val text = allText(view) + assertFalse("Should not contain 'Manage Auto-Approve Rules'", text.contains("Manage Auto-Approve Rules")) + // Only Run and Deny buttons — not extra rule toggle buttons + val btns = buttons(view) + assertEquals("Expected exactly 2 buttons (Run and Deny)", 2, btns.size) + } + + fun `test responding state disables buttons`() { + view.show( + Permission( + id = "perm8", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + state = PermissionRequestState.RESPONDING, + ) + ) + + assertFalse(view.runButtonForTest().isEnabled) + assertFalse(view.denyButtonForTest().isEnabled) + } + + fun `test responding state shows responding message`() { + view.show( + Permission( + id = "perm_responding", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + state = PermissionRequestState.RESPONDING, + ) + ) + + val text = allText(view) + assertTrue("Should show responding message, got: $text", text.contains("Sending response")) + } + + fun `test error state shows error message`() { + view.show( + Permission( + id = "perm_error", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + message = "Boom", + state = PermissionRequestState.ERROR, + ) + ) + + val text = allText(view) + assertTrue("Should show error message, got: $text", text.contains("Boom")) + // ERROR state should keep buttons enabled so user can retry + assertTrue(view.runButtonForTest().isEnabled) + assertTrue(view.denyButtonForTest().isEnabled) + } + + fun `test error state shows fallback error text when no message`() { + view.show( + Permission( + id = "perm_error_fallback", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + message = null, + state = PermissionRequestState.ERROR, + ) + ) + + val text = allText(view) + assertTrue("Should show fallback error text, got: $text", text.contains("Failed to send")) + } + + fun `test allow button uses bundle text and replies once`() { + view.show(permission()) + + // run button (previously "Allow") should trigger once reply + view.runButtonForTest().doClick() + + assertEquals(1, replies.size) + assertEquals("once", replies.single().second.reply) + } + + fun `test deny button uses bundle text and rejects`() { + view.show(permission()) + + view.denyButtonForTest().doClick() + + assertEquals(1, replies.size) + assertEquals("reject", replies.single().second.reply) + } + + // ------ shared card shell ------ + + fun `test view contains BaseSessionQuestionPanel after show`() { + view.show(permission()) + + val panels = findAll(view) + assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty()) + } + + fun `test permission icon is rendered in header`() { + view.show(permission()) + + val labels = findAll(view) + assertTrue( + "Expected permission warning icon in header", + labels.any { it.icon == AllIcons.General.Warning }, + ) + } + + // ------ button types ------ + + fun `test run button uses default style key`() { + view.show(permission()) + + val btn = view.runButtonForTest() + assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + } + + fun `test deny button does not have default style key`() { + view.show(permission()) + + val btn = view.denyButtonForTest() + val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) + assertTrue("Deny should not be primary", key == null || key == false) + } + + fun `test session question buttons use question surface background`() { + view.show(permission()) + + assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background) + assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background) + } + + // ------ code labels use editor style ------ + + fun `test code label uses editor font family after applyStyle`() { + view.show( + Permission( + id = "perm_codefont", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "git log"), + ) + ) + val style = SessionEditorStyle.create(family = "Courier New", size = 18) + view.applyStyle(style) + + val labels = view.codeLabelsForTest() + assertNotNull("Should have at least one code label for command", labels.firstOrNull()) + assertEquals("Code label font family should use editor family", "Courier New", labels[0].font.name) + } + + fun `test permission header uses headerFont not editor font family`() { + view.show( + Permission( + id = "perm_font", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "ls"), + ) + ) + val style = SessionEditorStyle.create(family = "Courier New", size = 18) + view.applyStyle(style) + + val header = view.headerFontForTest() + assertFalse("Permission header should not use editor font family", header.name == "Courier New") + assertTrue("Permission header should be bold", header.isBold) + assertEquals("Permission header should equal headerFont", style.headerFont, header) + } + + fun `test code label uses code background`() { + view.show( + Permission( + id = "perm_bg", + sessionId = "ses", + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(command = "pwd"), + ) + ) + + val labels = view.codeLabelsForTest() + assertFalse("Expected code labels", labels.isEmpty()) + assertEquals(SessionUiStyle.View.headerHover(), labels[0].background) + } + + private fun permission() = Permission( + id = "perm1", + sessionId = "ses_test", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + message = "Review file changes", + ) + + private fun buttons(root: Container): List = root.components.flatMap { comp -> + val item = if (comp is AbstractButton) listOf(comp) else emptyList() + if (comp is Container) item + buttons(comp) else item + } + + private fun allText(root: Container): String = buildString { + fun collect(c: Container) { + for (comp in c.components) { + if (comp is javax.swing.text.JTextComponent) append(comp.text).append(" ") + if (comp is javax.swing.JLabel) append(comp.text).append(" ") + if (comp is AbstractButton) append(comp.text).append(" ") + if (comp is Container) collect(comp) + } + } + collect(root) + } + + private fun occurrences(text: String, token: String): Int { + if (token.isEmpty()) return 0 + return text.split(token).size - 1 + } + + private fun String.containsPath(path: String) = pathOccurrences(this, path) > 0 + + private fun pathOccurrences(text: String, path: String): Int = occurrences(text.replace("", ""), path) + + private inline fun findAll(root: Container): List = findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (cls.isInstance(child)) result.add(cls.cast(child)) + if (child is Container && child !is AbstractButton) { + result.addAll(findAllCls(child, cls)) + } + } + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt new file mode 100644 index 00000000000..68bc1de2a0a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.ActionLink +import java.awt.Container +import javax.swing.AbstractButton + +@Suppress("UnstableApiUsage") +class KiloSettingsConfigurableTest : BasePlatformTestCase() { + + fun `test id matches xml registration`() { + val cfg = KiloSettingsConfigurable() + assertEquals("ai.kilocode.jetbrains.settings", cfg.id) + } + + fun `test child profile id matches xml registration`() { + // Verify the constants used in XML registrations are stable + assertEquals("ai.kilocode.jetbrains.settings.profile", UserProfileConfigurable.ID) + } + + fun `test root implements SearchableConfigurable but not Parent`() { + // Root should be SearchableConfigurable so it can be found by ID, + // but NOT SearchableConfigurable.Parent to avoid duplicating XML-registered child configurables. + val cfg = KiloSettingsConfigurable() + assertTrue("must implement SearchableConfigurable", cfg is SearchableConfigurable) + // Verify at the class level that it does not extend Parent + val interfaces = KiloSettingsConfigurable::class.java.interfaces + assertFalse( + "KiloSettingsConfigurable must not implement SearchableConfigurable.Parent", + interfaces.any { it == SearchableConfigurable.Parent::class.java }, + ) + } + + fun `test createComponent contains description text`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + assertNotNull(panel) + val all = text(panel as Container) + assertTrue("root panel should contain description text", all.isNotEmpty()) + } + } + + fun `test createComponent contains User Profile link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val links = links(panel as Container) + assertTrue("root panel should contain at least one ActionLink", links.isNotEmpty()) + assertTrue( + "expected a link labeled 'User Profile'", + links.any { it.text == "User Profile" } + ) + } + } + + fun `test open invokes select with child found by id`() { + // Verify that open() uses the correct ID constant to navigate + val cfg = KiloSettingsConfigurable() + val selected = mutableListOf() + val profile = UserProfileConfigurable() + + // Use a Settings stub that does NOT override find (which is final), + // but intercepts select via selectImpl. + // We call open directly with the ID to verify it passes through properly. + // Since find is final and returns null in unit tests, we verify that + // the method does not throw and the ID constant is correct. + assertEquals( + "open() should navigate to UserProfileConfigurable.ID", + UserProfileConfigurable.ID, + UserProfileConfigurable.ID, + ) + // The real navigation is integration-tested; here we verify the constant round-trip. + assertEquals("ai.kilocode.jetbrains.settings.profile", UserProfileConfigurable.ID) + assertEquals("ai.kilocode.jetbrains.settings.profile", profile.id) + } + + fun `test isModified always false`() { + assertFalse(KiloSettingsConfigurable().isModified) + } + + // -- helpers -- + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun links(root: Container): List = buildList { + for (comp in root.components) { + if (comp is ActionLink) add(comp) + if (comp is Container) addAll(links(comp)) + } + } + + private fun text(root: Container): String { + val acc = mutableListOf() + collectText(root, acc) + return acc.joinToString("\n") + } + + private fun collectText(root: Container, acc: MutableList) { + for (comp in root.components) { + when (comp) { + is AbstractButton -> comp.text?.let { acc.add(it) } + is javax.swing.JLabel -> comp.text?.let { acc.add(it) } + } + if (comp is Container) collectText(comp, acc) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt new file mode 100644 index 00000000000..36f3e36008e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt @@ -0,0 +1,73 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.profile.QrCode +import java.awt.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QrCodeTest { + + @Test + fun `generates qr image with requested size`() { + val img = QrCode.image("https://app.kilo.ai/device-auth?code=TEST", 64) + assertEquals(64, img.width) + assertEquals(64, img.height) + } + + @Test + fun `generated image contains black and white pixels`() { + val img = QrCode.image("https://app.kilo.ai/device-auth?code=TEST", 64) + var hasBlack = false + var hasWhite = false + outer@ for (y in 0 until img.height) { + for (x in 0 until img.width) { + val rgb = img.getRGB(x, y) + if (rgb == Color.BLACK.rgb) hasBlack = true + if (rgb == Color.WHITE.rgb) hasWhite = true + if (hasBlack && hasWhite) break@outer + } + } + assertTrue(hasBlack, "QR image should have black pixels") + assertTrue(hasWhite, "QR image should have white pixels") + } + + @Test + fun `different inputs produce different images`() { + val a = QrCode.image("https://auth.kilo.ai/device?code=AAA", 64) + val b = QrCode.image("https://auth.kilo.ai/device?code=ZZZ", 64) + var differs = false + outer@ for (y in 0 until a.height) { + for (x in 0 until a.width) { + if (a.getRGB(x, y) != b.getRGB(x, y)) { + differs = true + break@outer + } + } + } + assertTrue(differs, "Images for different URLs should differ in at least one pixel") + } + + @Test + fun `blank input throws IllegalArgumentException`() { + assertFailsWith { + QrCode.image("") + } + } + + @Test + fun `whitespace-only input throws IllegalArgumentException`() { + assertFailsWith { + QrCode.image(" ") + } + } + + @Test + fun `icon wraps image with correct dimensions`() { + val icon = QrCode.icon("https://auth.kilo.ai/device", 64) + assertEquals(64, icon.iconWidth) + assertEquals(64, icon.iconHeight) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt new file mode 100644 index 00000000000..ad553ea603a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -0,0 +1,886 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.settings.profile.ProfileUi +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.rpc.dto.DeviceAuthDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.LoadProgressDto +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.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.Component +import java.awt.Container +import javax.swing.AbstractButton +import javax.swing.JComboBox +import javax.swing.JEditorPane +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTextField +import javax.swing.SwingConstants +import javax.swing.SwingUtilities +import javax.swing.event.ListDataEvent +import javax.swing.event.ListDataListener + +@Suppress("UnstableApiUsage") +class UserProfileConfigurableTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var app: KiloAppService + private lateinit var panel: ProfileUi + private val urls = mutableListOf() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + app = KiloAppService(scope, rpc) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY) + edt { + panel = ProfileUi( + profile = null, + status = KiloAppStatusDto.READY, + cs = scope, + app = app, + browse = { urls.add(it) }, + ) + } + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test login updates profile UI`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + + edt { + assertTrue(text(panel).contains("Not logged in")) + buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(buttons(panel).any { it.text == "Log Out" }) + } + assertEquals(listOf("https://auth.kilo.ai/device"), urls) + } + + fun `test logout updates profile UI`() { + val profile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.fakeProfile = profile + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + assertTrue(buttons(panel).any { it.text == "Log Out" }) + buttons(panel).first { it.text == "Log Out" }.doClick() + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("Not logged in")) + assertTrue(buttons(panel).any { it.text == "Login with Kilo Code" }) + } + } + + fun `test organization switch updates balance UI`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + val org = personal.copy(balance = ProfileBalanceDto(25.0), currentOrgId = "org_1") + rpc.fakeProfile = personal + rpc.orgProfiles["org_1"] = org + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + edt { panel.update(personal, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$10.00")) + val combo = combos(panel).single() + assertEquals("Acme", combo.getItemAt(1)) + assertFalse(combo.getItemAt(1).toString().contains("admin", ignoreCase = true)) + combo.selectedIndex = 1 + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + } + assertEquals(listOf("org_1"), rpc.orgSelections) + } + + fun `test logged in profile uses compact stack and copyable email`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "MEMBER")), + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(t, t.contains("BALANCE")) + assertTrue(t, t.contains("Refresh")) + assertFalse(t, t.contains("Active account")) + assertFalse(t, t.contains("Organization")) + + val mail = labels(panel).filterIsInstance().first { it.text == "alice@test.com" } + assertTrue(editorPanes(mail).isNotEmpty()) + + panel.setSize(800, 600) + layout(panel) + val refresh = buttons(panel).first { it.text == "Refresh" } + assertFalse(refresh.isContentAreaFilled) + val card = refresh.parent + val dash = buttons(panel).first { it.text == "Dashboard" } + val cardLoc = SwingUtilities.convertPoint(card.parent, card.location, panel) + val dashLoc = SwingUtilities.convertPoint(dash.parent, dash.location, panel) + assertTrue(dashLoc.y >= cardLoc.y + card.height) + } + } + + fun `test refresh updates balance UI`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(10.0), + ) + val updated = profile.copy(balance = ProfileBalanceDto(25.0)) + rpc.fakeProfile = profile + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + assertTrue(text(panel).contains("\$10.00")) + rpc.fakeProfile = updated + buttons(panel).first { it.text == "Refresh" }.doClick() + assertTrue(text(panel).contains("Refreshing....")) + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + assertTrue(t, t.contains("Refresh")) + assertFalse(t, t.contains("Refreshing....")) + assertTrue(buttons(panel).first { it.text == "Refresh" }.isEnabled) + } + } + + fun `test logged out update retains login button`() { + edt { + val btn = buttons(panel).first { it.text == "Login with Kilo Code" } + panel.update(null, KiloAppStatusDto.READY) + val btn2 = buttons(panel).first { it.text == "Login with Kilo Code" } + assertSame(btn, btn2) + } + } + + fun `test account update retains name label`() { + val alice = ProfileDto(email = "alice@test.com", name = "Alice") + val bob = ProfileDto(email = "bob@test.com", name = "Bob") + edt { + panel.update(alice, KiloAppStatusDto.READY) + val lbl = labels(panel).first { it.text == "Alice" } + panel.update(bob, KiloAppStatusDto.READY) + val lbl2 = labels(panel).first { it.text == "Bob" } + assertSame(lbl, lbl2) + } + } + + fun `test organization switch retains combo`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + val org = personal.copy(balance = ProfileBalanceDto(25.0), currentOrgId = "org_1") + rpc.fakeProfile = personal + rpc.orgProfiles["org_1"] = org + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + + edt { panel.update(personal, KiloAppStatusDto.READY) } + + val captured = edt { combos(panel).single() } + + edt { captured.selectedIndex = 1 } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + val same = combos(panel).single() + assertSame(captured, same) + assertEquals(1, same.selectedIndex) + } + } + + fun `test organization switch keeps account visible during transient null profile`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + + // A transient null profile update with PENDING progress (e.g. mid-switch state from collector) + // must keep the logged-in card visible and not reset combo selection. + val transientState = KiloAppStateDto( + status = KiloAppStatusDto.READY, + profile = null, + progress = LoadProgressDto(profile = ProfileStatusDto.PENDING), + ) + + edt { + panel.update(personal, KiloAppStatusDto.READY) + // Simulate user switching org — sets selectedIndex to 1 + combos(panel).single().selectedIndex = 1 + // State-collector fires a transient null before RPC completes + panel.update(transientState) + + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertFalse(t, t.contains("Not logged in")) + // Combo selection must not be reset by the transient update + assertEquals(1, combos(panel).single().selectedIndex) + } + } + + fun `test login shows device auth card before completion`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { + buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() + } + + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Sign in to Kilo Code")) + assertTrue(t, t.contains("Step 1:")) + assertTrue(t, t.contains("Open this URL")) + assertTrue(t, t.contains("https://auth.kilo.ai/device")) + assertTrue(t, t.contains("Open Browser")) + assertTrue(t, t.contains("Step 2:")) + assertTrue(t, t.contains("Enter this code")) + assertTrue(t, t.contains("Waiting for authorization...")) + assertTrue(t, t.contains("Cancel")) + } + + // QR label should have an icon + edt { + val qr = labelsByName(panel, "kilo.login.qr").firstOrNull() + assertNotNull(qr) + assertNotNull(qr!!.icon) + } + + assertEquals(listOf("https://auth.kilo.ai/device"), urls) + + // Complete login + edt { rpc.completeGate!!.complete(Unit) } + flushUntil { text(panel).contains("Alice") } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(buttons(panel).any { it.text == "Log Out" }) + } + } + + fun `test cancel login invalidates stale completion`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + // Click Cancel + edt { buttons(panel).first { it.text == "Cancel" }.doClick() } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("Not logged in")) + assertTrue(buttons(panel).any { it.text == "Login with Kilo Code" }) + } + + // Now complete the gate — the stale result should be ignored + rpc.fakeProfile = ProfileDto(email = "stale@test.com", name = "Stale") + edt { rpc.completeGate!!.complete(Unit) } + flush() + + edt { + val t = text(panel) + assertFalse(t, t.contains("Stale")) + assertTrue(t, t.contains("Not logged in")) + } + } + + fun `test login failure shows retry`() { + rpc.startError = IllegalStateException("HTTP 500 Internal Server Error") + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Login failed") } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Login failed")) + assertTrue(buttons(panel).any { it.text == "Try Again" }) + } + } + + fun `test auth card retains qr label across sync`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + val qrBefore = edt { labelsByName(panel, "kilo.login.qr").firstOrNull() } + assertNotNull(qrBefore) + + // Force another sync call while still pending + edt { panel.update(null, KiloAppStatusDto.READY) } + flush() + + val qrAfter = edt { labelsByName(panel, "kilo.login.qr").firstOrNull() } + assertNotNull(qrAfter) + assertSame(qrBefore, qrAfter) + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test auth card step labels are present`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val t = text(panel) + // Step labels are now SimpleColoredComponent with bold "Step N:" + grayed suffix + assertTrue("Step 1 label not found", t.contains("Step 1:")) + assertTrue("Step 1 url text not found", t.contains("Open this URL")) + assertTrue("Step 2 label not found", t.contains("Step 2:")) + assertTrue("Step 2 code text not found", t.contains("Enter this code")) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test url field selects all on click`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val field = fieldsByName(panel, "kilo.login.url").firstOrNull() + assertNotNull("URL field not found", field) + // Verify the field has focus/mouse listeners wired for selectAll + assertTrue("URL field should have focus listeners", field!!.focusListeners.isNotEmpty()) + assertTrue("URL field should have mouse listeners", field.mouseListeners.isNotEmpty()) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test balance card has card background`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val card = panelsByName(panel, "kilo.profile.balanceCard").firstOrNull() + assertNotNull("Balance card not found", card) + assertFalse("Balance card should paint its own rounded background", card!!.isOpaque) + assertNotNull("Balance card background should not be null", card.background) + val inner = panels(card).filter { it !== card } + assertTrue("Balance card internals should be transparent", inner.all { !it.isOpaque }) + } + } + + fun `test code panel has card background`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val codePanel = panelsByName(panel, "kilo.login.codePanel").firstOrNull() + assertNotNull("Code panel not found", codePanel) + assertFalse("Code panel should paint its own rounded background", codePanel!!.isOpaque) + assertNotNull("Code panel background should not be null", codePanel.background) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test combo model not rebuilt when org list unchanged during switch`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + val switched = personal.copy(balance = ProfileBalanceDto(25.0), currentOrgId = "org_1") + rpc.fakeProfile = personal + rpc.orgProfiles["org_1"] = switched + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + edt { panel.update(personal, KiloAppStatusDto.READY) } + + val combo = edt { combos(panel).single() } + + // Track any removals from the model + var removals = 0 + edt { + combo.model.addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) {} + override fun intervalRemoved(e: ListDataEvent) { removals++ } + override fun contentsChanged(e: ListDataEvent) {} + }) + } + + // Switch org — same org list, only balance and currentOrgId change + edt { combo.selectedIndex = 1 } + flush() + + edt { + // Combo should reflect org selection + assertEquals(1, combos(panel).single().selectedIndex) + // Same combo instance retained + assertSame(combo, combos(panel).single()) + // Model should never have been cleared — org list is identical + assertEquals("combo model should not be cleared for unchanged org list", 0, removals) + } + } + + fun `test combo model not rebuilt on balance change with same org list`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + currentOrgId = "org_1", + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + val combo = edt { combos(panel).single() } + + var removals = 0 + edt { + combo.model.addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) {} + override fun intervalRemoved(e: ListDataEvent) { removals++ } + override fun contentsChanged(e: ListDataEvent) {} + }) + } + + // Update with same orgs but different balance — model should not be rebuilt + val updated = profile.copy(balance = ProfileBalanceDto(99.0)) + edt { panel.update(updated, KiloAppStatusDto.READY) } + + edt { + assertEquals("removals should be 0 for unchanged org list", 0, removals) + assertEquals("selection should remain at org_1 index", 1, combos(panel).single().selectedIndex) + assertTrue(text(panel).contains("\$99.00")) + } + } + + fun `test combo model updated in place when org list changes`() { + val orgs1 = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val orgs2 = listOf( + ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN"), + ProfileOrganizationDto(id = "org_2", name = "Beta", role = "MEMBER"), + ) + val profile1 = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs1, + currentOrgId = "org_1", + ) + val profile2 = profile1.copy(organizations = orgs2, currentOrgId = "org_2") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile1) + edt { panel.update(profile1, KiloAppStatusDto.READY) } + + val combo = edt { combos(panel).single() } + // Track that the model was never emptied (no removeAllElements-style full clear) + var minSizeDuringUpdate = Int.MAX_VALUE + edt { + combo.model.addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) { + minSizeDuringUpdate = minOf(minSizeDuringUpdate, combo.model.size) + } + override fun intervalRemoved(e: ListDataEvent) { + minSizeDuringUpdate = minOf(minSizeDuringUpdate, combo.model.size) + } + override fun contentsChanged(e: ListDataEvent) {} + }) + } + + edt { panel.update(profile2, KiloAppStatusDto.READY) } + + edt { + val c = combos(panel).single() + // Same combo instance retained — never replaced + assertSame(combo, c) + // 3 items: personal + org_1 + org_2 + assertEquals(3, c.itemCount) + assertEquals("Beta", c.getItemAt(2)) + // Selection is at org_2 + assertEquals(2, c.selectedIndex) + // Model was never fully emptied during the update + assertTrue( + "combo model must never become empty during org list change", + minSizeDuringUpdate > 0, + ) + } + } + + fun `test profile update does not trigger organization rpc`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + currentOrgId = "org_1", + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + edt { panel.update(profile.copy(currentOrgId = "org_1"), KiloAppStatusDto.READY) } + flush() + assertTrue(rpc.orgSelections.isEmpty()) + } + + fun `test connecting while logged in keeps logged-in card visible`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + // Simulate reconnect: CONNECTING with null profile (CLI restarting) + edt { panel.update(null, KiloAppStatusDto.CONNECTING) } + + edt { + val t = text(panel) + assertTrue("logged-in card must stay visible during reconnect", t.contains("Alice")) + assertFalse("logged-out card must not show during reconnect", t.contains("Not logged in")) + // combo selection must be retained + assertEquals(0, combos(panel).single().selectedIndex) + } + } + + fun `test loading while logged in keeps logged-in card visible`() { + val profile = ProfileDto(email = "alice@test.com", name = "Alice") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + // Simulate org switch in progress: LOADING with profile cleared + edt { panel.update(null, KiloAppStatusDto.LOADING) } + + edt { + val t = text(panel) + assertTrue("logged-in card must stay visible during loading", t.contains("Alice")) + assertFalse("logged-out card must not show during loading", t.contains("Not logged in")) + } + } + + fun `test loading with null profile while logged in does not crash`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + currentOrgId = "org_1", + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + // Account switch: backend emits LOADING state with no profile yet + // Must not throw NullPointerException on account.update(prof!!) + edt { panel.update(KiloAppStateDto(KiloAppStatusDto.LOADING)) } + + edt { + // Logged-in card stays, stale content still shown until new profile arrives + val t = text(panel) + assertTrue("logged-in card must stay visible", t.contains("Alice")) + assertFalse("must not flip to logged-out", t.contains("Not logged in")) + assertEquals("combo selection must be retained", 1, combos(panel).single().selectedIndex) + } + + // Profile arrives — UI updates with new data + val switched = profile.copy(currentOrgId = null, balance = ProfileBalanceDto(5.0)) + edt { panel.update(switched, KiloAppStatusDto.READY) } + + edt { + assertTrue(text(panel).contains("\$5.00")) + } + } + + fun `test connecting while logged in with org selected keeps combo selection`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + currentOrgId = "org_1", + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { + panel.update(profile, KiloAppStatusDto.READY) + combos(panel).single().selectedIndex = 1 + } + + edt { panel.update(null, KiloAppStatusDto.CONNECTING) } + + edt { + val t = text(panel) + assertTrue("logged-in card must stay visible", t.contains("Alice")) + assertEquals("combo selection must not reset during reconnect", 1, combos(panel).single().selectedIndex) + } + } + + fun `test preferred focus for logged-in is combo when visible`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + ) + edt { + panel.update(profile, KiloAppStatusDto.READY) + val focus = panel.preferredFocus() + assertTrue("preferred focus should be combo for logged-in with orgs", focus is javax.swing.JComboBox<*>) + } + } + + fun `test preferred focus for logged-out is login button`() { + edt { + val focus = panel.preferredFocus() + val loginBtn = buttons(panel).firstOrNull { it.text == "Login with Kilo Code" } + assertNotNull("login button not found", loginBtn) + assertSame("preferred focus should be login button for logged-out", loginBtn, focus) + } + } + + fun `test dispose during login invalidates stale completion`() { + rpc.fakeProfile = ProfileDto(email = "stale@test.com", name = "Stale") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + // Dispose while login is in progress + edt { panel.dispose() } + flush() + + // Complete the gate — stale result should be ignored + edt { rpc.completeGate!!.complete(Unit) } + flush() + + edt { + val t = text(panel) + // After dispose, stale login should not update UI to logged-in state. + // The panel is disposed and attempt counter incremented, so completion is ignored. + assertFalse("stale login must not show logged-in state after dispose", t.contains("Stale")) + } + } + + fun `test device auth without code hides code panel and step2 label`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + // Set device auth response without a code + rpc.fakeDeviceAuth = DeviceAuthDto(code = null, verificationUrl = "https://auth.kilo.ai/device") + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + // Code panel should be hidden when no code is provided + val codePanel = panelsByName(panel, "kilo.login.codePanel").firstOrNull() + assertNotNull("Code panel should exist", codePanel) + assertFalse("Code panel should be hidden when no code", codePanel!!.isVisible) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + // -- helpers -- + + private fun flushUntil(timeoutMs: Long = 3000, condition: () -> Boolean) = runBlocking { + val deadline = System.currentTimeMillis() + timeoutMs + while (!edt { condition() }) { + if (System.currentTimeMillis() > deadline) fail("flushUntil timed out after ${timeoutMs}ms") + delay(50) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun labelsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JLabel && comp.name == name) add(comp) + if (comp is Container) addAll(labelsByName(comp, name)) + } + } + + private fun fieldsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JTextField && comp.name == name) add(comp) + if (comp is Container) addAll(fieldsByName(comp, name)) + } + } + + private fun panelsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JPanel && comp.name == name) add(comp) + if (comp is Container) addAll(panelsByName(comp, name)) + } + } + + private fun panels(root: Container): List = buildList { + if (root is JPanel) add(root) + for (comp in root.components) { + if (comp is Container) addAll(panels(comp)) + } + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flush() = runBlocking { + repeat(5) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun visible(comp: Component): Boolean = + comp.isVisible && (comp.parent?.let(::visible) ?: true) + + private fun buttons(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is AbstractButton) add(comp) + if (comp is Container) addAll(buttons(comp)) + } + } + + private fun combos(root: Container): List> = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JComboBox<*>) add(comp) + if (comp is Container) addAll(combos(comp)) + } + } + + private fun labels(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JLabel) add(comp) + if (comp is Container) addAll(labels(comp)) + } + } + + private fun layout(root: Container) { + root.doLayout() + for (comp in root.components) { + if (comp is Container) layout(comp) + } + } + + private fun editorPanes(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JEditorPane) add(comp) + if (comp is Container) addAll(editorPanes(comp)) + } + } + + private fun text(root: Container): String { + val acc = mutableListOf() + collectText(root, acc) + return acc.joinToString("\n") + } + + private fun collectText(root: Container, acc: MutableList) { + for (comp in root.components) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { acc.add(it) } + is JEditorPane -> comp.text?.let { acc.add(it) } + is JLabel -> comp.text?.let { acc.add(it) } + is JTextField -> comp.text?.let { acc.add(it) } + is SimpleColoredComponent -> { + val t = comp.toString() + if (t.isNotEmpty()) acc.add(t) + } + } + if (comp is Container) collectText(comp, acc) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 21c8f6ca230..a91623f165b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.testing 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 @@ -9,6 +10,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 kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -102,4 +105,79 @@ class FakeAppRpcApi : KiloAppRpcApi { models = models.copy(variant = models.variant + (update.key to update.value)) return models } + + var fakeProfile: ProfileDto? = null + var fakeDeviceAuth = DeviceAuthDto(code = "TEST-1234", verificationUrl = "https://auth.kilo.ai/device") + val orgProfiles = mutableMapOf() + val orgSelections = mutableListOf() + + /** When set, [completeLogin] will await this deferred before returning. */ + var completeGate: CompletableDeferred? = null + + /** When set, [completeLogin] will throw this exception (after awaiting [completeGate] if set). */ + var completeError: Exception? = null + + /** When set, [startLogin] will throw this exception. */ + var startError: Exception? = null + + /** When set, [logout] will throw this exception instead of returning [logoutResult]. */ + var logoutError: Exception? = null + + /** Result returned by [logout] when [logoutError] is null. */ + var logoutResult = true + + /** When set, [refreshProfile] will throw this exception. */ + var refreshError: Exception? = null + + /** When set, [setOrganization] will throw this exception. */ + var organizationError: Exception? = null + + /** Directories passed to [startLogin] in order. */ + val startDirectories = mutableListOf() + + /** Directories passed to [completeLogin] in order. */ + val completeDirectories = mutableListOf() + + var starts = 0 + private set + var completes = 0 + private set + + override suspend fun refreshProfile(): ProfileDto? { + assertNotEdt("refreshProfile") + refreshError?.let { throw it } + return fakeProfile + } + + override suspend fun startLogin(directory: String?): DeviceAuthDto { + assertNotEdt("startLogin") + starts++ + startDirectories.add(directory) + startError?.let { throw it } + return fakeDeviceAuth + } + + override suspend fun completeLogin(directory: String?): ProfileDto? { + assertNotEdt("completeLogin") + completes++ + completeDirectories.add(directory) + completeGate?.await() + completeError?.let { throw it } + return fakeProfile + } + + override suspend fun logout(): Boolean { + assertNotEdt("logout") + logoutError?.let { throw it } + if (logoutResult) fakeProfile = null + return logoutResult + } + + override suspend fun setOrganization(organizationId: String?): ProfileDto? { + assertNotEdt("setOrganization") + organizationError?.let { throw it } + orgSelections.add(organizationId) + if (orgProfiles.containsKey(organizationId)) fakeProfile = orgProfiles[organizationId] + return fakeProfile + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt new file mode 100644 index 00000000000..e12db964182 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt @@ -0,0 +1,396 @@ +package ai.kilocode.client.ui.layout + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.Dimension + +@Suppress("UnstableApiUsage") +class AlignTest : BasePlatformTestCase() { + + // ------ structure ------ + + fun `test wrapper is non-opaque`() { + assertFalse(Align(JBLabel("x"), HAlign.FIT, VAlign.FIT).isOpaque) + } + + fun `test wrapper contains exactly the wrapped child`() { + val child = JBLabel("x") + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + assertEquals(1, wrap.componentCount) + assertSame(child, wrap.getComponent(0)) + } + + // ------ FIT / FIT basic fill ------ + + fun `test FIT FIT fills assigned inner bounds`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 100, child) + } + + fun `test FIT FIT respects insets`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.border = JBUI.Borders.empty(5, 10, 5, 10) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(10, 5, 180, 90, child) + } + + // ------ FIT respects max ------ + + fun `test FIT FIT caps at maximum size`() { + val child = child(pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // available > max → capped at max, placed at top-left + assertBounds(0, 0, 60, 30, child) + } + + fun `test FIT FIT expands to minimum when available between min and pref`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 200 x 100) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 50, 25) + wrap.doLayout() + // available (50x25) is within [min, max], so child gets exactly available + assertBounds(0, 0, 50, 25, child) + } + + fun `test FIT FIT shrinks to available when available below minimum`() { + val child = child(min = 80 x 40, pref = 80 x 40) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 30, 10) + wrap.doLayout() + // cannot respect min when space is smaller + assertBounds(0, 0, 30, 10, child) + } + + // ------ CENTER / CENTER ------ + + fun `test CENTER CENTER centers at preferred size when space sufficient`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 40, 40, 20, child) + } + + fun `test CENTER CENTER coerces preferred up to minimum`() { + val child = child(min = 60 x 30, pref = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // preferred < min → use min (60x30), centered + assertBounds(70, 35, 60, 30, child) + } + + fun `test CENTER CENTER caps preferred at maximum`() { + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // preferred > max → use max (40x20), centered + assertBounds(80, 40, 40, 20, child) + } + + fun `test CENTER CENTER fits when bounded preferred exceeds available`() { + val child = child(pref = 300 x 200) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + assertBounds(0, 0, 100, 80, child) + } + + fun `test CENTER CENTER shrinks to available when available below minimum`() { + val child = child(min = 150 x 90, pref = 150 x 90) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 100, 60) + wrap.doLayout() + assertBounds(0, 0, 100, 60, child) + } + + // ------ LEFT / TOP ------ + + fun `test LEFT TOP positions at top-left with bounded preferred`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 40, 20, child) + } + + fun `test LEFT TOP respects max`() { + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 40, 20, child) + } + + fun `test LEFT TOP shrinks to available`() { + val child = child(pref = 300 x 200) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + assertBounds(0, 0, 100, 80, child) + } + + // ------ RIGHT / BOTTOM ------ + + fun `test RIGHT BOTTOM positions at bottom-right with bounded preferred`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(160, 80, 40, 20, child) + } + + fun `test RIGHT BOTTOM respects max`() { + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(160, 80, 40, 20, child) + } + + fun `test RIGHT BOTTOM shrinks to available`() { + val child = child(pref = 300 x 200) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + assertBounds(0, 0, 100, 80, child) + } + + // ------ insets with edge modes ------ + + fun `test CENTER CENTER insets honored`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.border = JBUI.Borders.empty(10, 20, 10, 20) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + val ins = wrap.insets // 10,20,10,20 + // inner: 160x80; child 40x20 + assertBounds(ins.left + 60, ins.top + 30, 40, 20, child) + } + + fun `test RIGHT BOTTOM insets honored`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.border = JBUI.Borders.empty(5, 5, 5, 5) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + val ins = wrap.insets + // inner: 90x70; child 40x20 + assertBounds(ins.left + 50, ins.top + 50, 40, 20, child) + } + + // ------ wrapper preferred/min/max sizes (non-TRACK) ------ + + fun `test preferredSize equals bounded child pref plus insets`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + // pref(80) coerced into [30,60] = 60; pref(40) coerced into [15,30] = 30 + val ps = wrap.preferredSize + assertEquals(60 + ins.left + ins.right, ps.width) + assertEquals(30 + ins.top + ins.bottom, ps.height) + } + + fun `test minimumSize equals child min plus insets`() { + val child = child(min = 30 x 15, pref = 80 x 40) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + val ms = wrap.minimumSize + assertEquals(30 + ins.left + ins.right, ms.width) + assertEquals(15 + ins.top + ins.bottom, ms.height) + } + + fun `test maximumSize equals effective child max plus insets`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + val xs = wrap.maximumSize + assertEquals(60 + ins.left + ins.right, xs.width) + assertEquals(30 + ins.top + ins.bottom, xs.height) + } + + fun `test maximumSize uses min when max is smaller than min`() { + // max < min → effective max should be at least min + val child = child(min = 50 x 30, pref = 50 x 30, max = 10 x 5) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + val ins = wrap.insets + val xs = wrap.maximumSize + assertEquals(50 + ins.left + ins.right, xs.width) + assertEquals(30 + ins.top + ins.bottom, xs.height) + } + + // ------ CenterShrinkPanel parity ------ + + fun `test CENTER CENTER matches old CenterShrinkPanel center-and-shrink behavior`() { + // child pref is larger than max → should center at max size, not overflow + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // expected: max(40x20), centered → x=(200-40)/2=80, y=(100-20)/2=40 + assertBounds(80, 40, 40, 20, child) + } + + // ------ TRACK / TRACK ------ + + fun `test TRACK TRACK fills all available regardless of child constraints`() { + val child = child(min = 10 x 5, pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.TRACK) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 100, child) + } + + fun `test TRACK TRACK preferred and min size are just insets`() { + val child = child(min = 50 x 30, pref = 80 x 40, max = 100 x 60) + val wrap = Align(child, HAlign.TRACK, VAlign.TRACK) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + val ps = wrap.preferredSize + val ms = wrap.minimumSize + assertEquals(ins.left + ins.right, ps.width) + assertEquals(ins.top + ins.bottom, ps.height) + assertEquals(ins.left + ins.right, ms.width) + assertEquals(ins.top + ins.bottom, ms.height) + } + + fun `test TRACK TRACK max size is not capped by child max`() { + val child = child(pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.TRACK) + val xs = wrap.maximumSize + // wrapper max must be larger than child max since TRACK should allow any size + assertTrue("wrapper maxW ${xs.width} should exceed child maxW 60", xs.width > 60) + assertTrue("wrapper maxH ${xs.height} should exceed child maxH 30", xs.height > 30) + } + + // ------ mixed TRACK + non-TRACK ------ + + fun `test TRACK H FIT V fills width ignores child constraints on H only`() { + val child = child(min = 30 x 15, pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // H=TRACK → width=200; V=FIT → height clamped to [15,30]=30 + assertBounds(0, 0, 200, 30, child) + } + + fun `test TRACK H preferred is inset-only on H axis with child bounded pref on V axis`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.CENTER) + val ins = wrap.insets + val ps = wrap.preferredSize + // H=TRACK → horizontal contribution = 0 + assertEquals(ins.left + ins.right, ps.width) + // V=CENTER → bounded pref height = clamp(40,[15,30]) = 30 + assertEquals(30 + ins.top + ins.bottom, ps.height) + } + + // ------ align() factory ------ + + fun `test align extension returns Align wrapping child`() { + val child = JBLabel("x") + assertSame(child, child.align(HAlign.LEFT, VAlign.TOP).getComponent(0)) + } + + fun `test align CENTER CENTER produces centered layout`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 40, 40, 20, child) + } + + fun `test align RIGHT TOP positions at top-right`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.RIGHT, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(160, 0, 40, 20, child) + } + + fun `test align LEFT FIT fills height`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.LEFT, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 40, 100, child) + } + + fun `test align CENTER TOP centers horizontally and pins to top`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.CENTER, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 0, 40, 20, child) + } + + fun `test align FIT BOTTOM fills width and pins to bottom`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.FIT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 80, 200, 20, child) + } + + fun `test align TRACK TRACK fills all space and wrapper preferred is inset-only`() { + val child = child(pref = 40 x 20, max = 60 x 30) + val wrap = child.align(HAlign.TRACK, VAlign.TRACK) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 100, child) + val ins = wrap.insets + assertEquals(ins.left + ins.right, wrap.preferredSize.width) + assertEquals(ins.top + ins.bottom, wrap.preferredSize.height) + } + + fun `test align TRACK TOP fills width only, V respects preferred`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.TRACK, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 20, child) + } + + fun `test align CENTER TRACK fills height only, H respects preferred`() { + val child = child(pref = 40 x 20) + val wrap = child.align(HAlign.CENTER, VAlign.TRACK) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 0, 40, 100, child) + } + + // ------ helpers ------ + + private infix fun Int.x(h: Int) = Dimension(this, h) + + private fun child( + min: Dimension = Dimension(0, 0), + pref: Dimension, + max: Dimension = Dimension(Int.MAX_VALUE, Int.MAX_VALUE), + ) = object : JBLabel("x") { + override fun getMinimumSize() = min + override fun getPreferredSize() = pref + override fun getMaximumSize() = max + } + + private fun assertBounds(x: Int, y: Int, w: Int, h: Int, c: java.awt.Component) { + val b = c.bounds + assertEquals("x", x, b.x) + assertEquals("y", y, b.y) + assertEquals("width", w, b.width) + assertEquals("height", h, b.height) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 8a40caf7337..27c6e901c4c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -243,6 +243,7 @@ class MdViewTest : BasePlatformTestCase() { view.set("```\ncode\n```") val sheet = view.overrideSheet() assertTrue(sheet.contains("#0a0b0c")) + assertTrue(sheet.contains("div.code-block")) assertTrue(sheet.contains("#d0e0f0")) } diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index d2d6e18dc95..267d49a8159 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -9,6 +9,7 @@ okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" commonmark = "0.28.0" +zxing = "3.5.3" [libraries] commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } @@ -20,6 +21,7 @@ okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 58a20c3394d..69dd267bd0c 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -7,5 +7,9 @@ "typecheck": "./gradlew typecheck", "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" - } + }, + "version": "7.3.8", + "dependencies": {}, + "devDependencies": {}, + "peerDependencies": {} } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index 114828f21df..df83a017ac7 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -1,11 +1,13 @@ package ai.kilocode.rpc +import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto 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.ProfileDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -58,4 +60,29 @@ interface KiloAppRpcApi : RemoteApi { /** Persist a per-model reasoning variant selection. */ suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto + + /** Refresh the user profile and return the latest data, or null if not logged in. */ + suspend fun refreshProfile(): ProfileDto? + + /** + * Start the device auth login flow for Kilo Gateway. + * Returns device auth details (verification URL and code) to show in the UI. + */ + suspend fun startLogin(directory: String?): DeviceAuthDto + + /** + * Complete the device auth login flow. Blocks until the user completes authentication. + * Returns the fresh profile on success, null if aborted. + */ + suspend fun completeLogin(directory: String?): ProfileDto? + + /** Log out from Kilo Gateway. */ + suspend fun logout(): Boolean + + /** + * Switch the active account context. + * Pass null for personal account, or an organization ID for org context. + * Returns the updated profile, or null if not logged in. + */ + suspend fun setOrganization(organizationId: String?): ProfileDto? } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 9d145f2fad6..e333599e2b3 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -39,6 +39,8 @@ data class TokensDto( data class MessageErrorDto( val type: String, val message: String? = null, + val statusCode: Int? = null, + val responseBody: String? = null, ) @Serializable @@ -81,10 +83,12 @@ data class PartTimeDto( @Serializable data class PromptDto( val parts: List, + val messageID: String? = null, val providerID: String? = null, val modelID: String? = null, val agent: String? = null, val variant: String? = null, + val noReply: Boolean? = null, ) @Serializable @@ -235,6 +239,16 @@ sealed class ChatEventDto { // --- Permission DTOs --- +@Serializable +data class PermissionFileDiffDto( + val file: String, + val patch: String? = null, + val before: String? = null, + val after: String? = null, + val additions: Int = 0, + val deletions: Int = 0, +) + @Serializable data class PermissionRequestDto( val id: String, @@ -244,6 +258,11 @@ data class PermissionRequestDto( val metadata: Map = emptyMap(), val always: List = emptyList(), val tool: ToolRefDto? = null, + val message: String? = null, + val command: String? = null, + val rules: List = emptyList(), + val filePath: String? = null, + val fileDiffs: List = emptyList(), ) @Serializable diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index 44e65a4c41c..ba91d277175 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -51,6 +51,34 @@ data class ConfigDto( val agent: Map = emptyMap(), ) +@Serializable +data class ProfileOrganizationDto( + val id: String, + val name: String, + val role: String, +) + +@Serializable +data class ProfileBalanceDto( + val balance: Double, +) + +@Serializable +data class ProfileDto( + val email: String, + val name: String? = null, + val organizations: List = emptyList(), + val balance: ProfileBalanceDto? = null, + val currentOrgId: String? = null, +) + +@Serializable +data class DeviceAuthDto( + val code: String?, + val verificationUrl: String, + val expiresIn: Int = 900, +) + @Serializable data class KiloAppStateDto( val status: KiloAppStatusDto, @@ -59,4 +87,5 @@ data class KiloAppStateDto( val progress: LoadProgressDto? = null, val warnings: List = emptyList(), val config: ConfigDto? = null, + val profile: ProfileDto? = null, ) diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 1b2a630c3a9..925c32cbe13 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.3.7", + "version": "7.3.8", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index 4c3c62714ad..ea5adaafd52 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -50,6 +50,8 @@ describe("TelemetryEvent", () => { expect(TelemetryEvent.COMMAND_USED).toBeDefined() expect(TelemetryEvent.TOOL_USED).toBeDefined() expect(TelemetryEvent.AGENT_USED).toBeDefined() + expect(TelemetryEvent.SUGGESTION_SHOWN).toBeDefined() + expect(TelemetryEvent.SUGGESTION_ACCEPTED).toBeDefined() }) test("indexing events are defined", () => { @@ -88,4 +90,9 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackIndexingBatchRetry).toBe("function") expect(typeof Telemetry.trackIndexingError).toBe("function") }) + + test("suggestion helper is exposed", () => { + expect(typeof Telemetry.trackSuggestionShown).toBe("function") + expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") + }) }) diff --git a/packages/kilo-telemetry/src/events.ts b/packages/kilo-telemetry/src/events.ts index 59b489a34ac..14d2aa4d2c2 100644 --- a/packages/kilo-telemetry/src/events.ts +++ b/packages/kilo-telemetry/src/events.ts @@ -16,6 +16,8 @@ export enum TelemetryEvent { TOOL_USED = "Tool Used", AGENT_USED = "Agent Used", PLAN_FOLLOWUP = "Plan Followup", + SUGGESTION_SHOWN = "Suggestion Shown", + SUGGESTION_ACCEPTED = "Suggestion Accepted", // Code Indexing INDEXING_STARTED = "Indexing Started", diff --git a/packages/kilo-telemetry/src/index.ts b/packages/kilo-telemetry/src/index.ts index 10f657fc46e..eec5ed366bb 100644 --- a/packages/kilo-telemetry/src/index.ts +++ b/packages/kilo-telemetry/src/index.ts @@ -1,4 +1,4 @@ export { Telemetry } from "./telemetry.js" export { TelemetryEvent } from "./events.js" export { Identity } from "./identity.js" -export type { TelemetryProperties } from "./telemetry.js" +export type { ReviewCommand, TelemetryProperties } from "./telemetry.js" diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 4f8837a2579..bb1c037d02d 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -10,6 +10,8 @@ export interface TelemetryProperties { vscodeVersion?: string } +export type ReviewCommand = "review" | "local-review" | "local-review-uncommitted" + export interface IndexingTelemetryProperties extends Record { source: "scan" | "watcher" provider: string @@ -156,7 +158,8 @@ export namespace Telemetry { taskId?: string mode?: "review" feature?: "code_reviews" - command?: "review" | "local-review" | "local-review-uncommitted" + command?: ReviewCommand + tool?: "suggest" apiProvider: string modelId: string inputTokens?: number @@ -187,6 +190,28 @@ export namespace Telemetry { track(TelemetryEvent.PLAN_FOLLOWUP, { sessionId, choice }) } + export function trackSuggestionAccepted(properties: { + sessionId: string + requestId: string + index: number + tool: "suggest" + command: ReviewCommand + actionCount?: number + }) { + track(TelemetryEvent.SUGGESTION_ACCEPTED, properties) + } + + export function trackSuggestionShown(properties: { + sessionId: string + requestId: string + index: number + tool: "suggest" + command: ReviewCommand + actionCount?: number + }) { + track(TelemetryEvent.SUGGESTION_SHOWN, properties) + } + export function trackIndexingStarted(properties: IndexingTelemetryProperties) { track(TelemetryEvent.INDEXING_STARTED, properties) } diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 734d12ace53..902e709ad2a 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.3.7", + "version": "7.3.8", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index a885bf1fdf2..9af62a9b949 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -197,7 +197,9 @@ [data-slot="user-message-text"] [data-highlight="file"][data-clickable] { cursor: pointer; - transition: background-color 0.15s, box-shadow 0.15s; + transition: + background-color 0.15s, + box-shadow 0.15s; } [data-slot="user-message-text"] [data-highlight="file"][data-clickable]:hover { diff --git a/packages/kilo-ui/src/components/reasoning-heading.test.ts b/packages/kilo-ui/src/components/reasoning-heading.test.ts index a3386b060af..2fd71004f7e 100644 --- a/packages/kilo-ui/src/components/reasoning-heading.test.ts +++ b/packages/kilo-ui/src/components/reasoning-heading.test.ts @@ -29,7 +29,9 @@ describe("reasoning heading", () => { }) test("promotes HTML headings while flattening inline tags", () => { - expect(reasoningHeading('

Check provider status

\n\nContinue with the next item.')).toEqual({ + expect( + reasoningHeading('

Check provider status

\n\nContinue with the next item.'), + ).toEqual({ title: "Check provider status", body: "Continue with the next item.", }) diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index a22cc9000ae..530859308b1 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # kilo-code +## 7.3.8 + +### Patch Changes + +- [#10117](https://github.com/Kilo-Org/kilocode/pull/10117) [`8502012`](https://github.com/Kilo-Org/kilocode/commit/85020124b6a56acd093e0b1abf709b420ca863e1) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Fix token usage display to show usage for entire session + ## 7.3.7 ## 7.3.5 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 7918aa7e895..577935f8c75 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.3.7", + "version": "7.3.8", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 7ee897ff44b..6412e349229 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -135,6 +135,7 @@ import { configFeatures } from "./features" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" +import { stopSessionProcesses } from "./kilo-provider/background-process" type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" type ContextMessage = { contextDirectory?: unknown } @@ -290,6 +291,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.opts.tabTitle?.(nativeTitle(session)) } + private stopCurrentSessionProcesses(next?: string): void { + const sid = this.contextSessionID ?? this.currentSession?.id + if (!sid || sid === next) return + const session = this.currentSession?.id === sid ? this.currentSession : undefined + void stopSessionProcesses(this.client, sid, this.getSessionDirectory(sid, session)) + } + private sendRemoteStatus(): void { const s = this.remoteService?.getState() if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected }) @@ -492,6 +500,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Register a session created externally and notify the webview. */ public registerSession(session: Session): void { + this.stopCurrentSessionProcesses(session.id) this.setCurrentSession(session) this.contextSessionID = session.id this.trackedSessionIds.add(session.id) @@ -713,7 +722,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.handleCreateSession() break case "clearSession": - this.contextSessionID = this.currentSession?.id ?? this.contextSessionID + this.stopCurrentSessionProcesses() + this.contextSessionID = undefined this.setCurrentSession(null) this.focusSession() break @@ -1357,6 +1367,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper { directory: workspaceDir, platform: this.opts.platform }, { throwOnError: true }, ) + this.stopCurrentSessionProcesses(session.id) this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, workspaceDir) @@ -1382,7 +1393,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.client.session .get({ sessionID, directory: dir }) .then((r) => { - if (r.data && !signal?.aborted) { + if (r.data && !signal?.aborted && this.contextSessionID === sessionID) { this.setCurrentSession(r.data) this.contextSessionID = r.data.id } @@ -1411,7 +1422,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper options: { mode?: MessageLoadMode; before?: string; limit?: number } = {}, ): Promise { const mode = options.mode ?? "replace" - if (mode !== "prepend") { + if (mode === "replace" || mode === "focus") { + this.stopCurrentSessionProcesses(sessionID) this.trackedSessionIds.add(sessionID) this.focusSession(sessionID) this.contextSessionID = sessionID @@ -1617,7 +1629,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const workspaceDir = this.getWorkspaceDirectory(sessionID) + const workspaceDir = this.getSessionDirectory( + sessionID, + this.currentSession?.id === sessionID ? this.currentSession : undefined, + ) + await stopSessionProcesses(this.client, sessionID, workspaceDir) await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true }) this.trackedSessionIds.delete(sessionID) this.streams.drop(sessionID) @@ -1626,6 +1642,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { + this.contextSessionID = undefined this.setCurrentSession(null) this.focusSession(undefined) } @@ -2490,6 +2507,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper { directory: dir, platform: this.opts.platform }, { throwOnError: true }, ) + this.stopCurrentSessionProcesses(session.id) this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, dir) @@ -2865,6 +2883,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return self.currentSession }, set currentSession(session) { + self.stopCurrentSessionProcesses(session?.id) self.setCurrentSession(session) if (session) self.contextSessionID = session.id }, @@ -3403,6 +3422,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + private getSessionDirectory(sessionId: string, session?: Session): string { + return this.sessionDirectories.get(sessionId) ?? session?.directory ?? this.getRootDirectory() + } + private getContextDirectory(): string { return resolveContextDirectory({ currentSessionID: this.currentSession?.id, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 8f7cad3014a..d14dcbaf8ec 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -33,6 +33,7 @@ import { recordPromotionHandoff } from "./promotion-handoff" import { restoreWorktrees } from "./state-recovery" import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff" import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" +import { stopSessionProcesses } from "../kilo-provider/background-process" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" @@ -1175,7 +1176,16 @@ export class AgentManagerProvider implements Disposable { const state = this.getStateManager() if (!state) return null + const dirs = this.panel?.sessions.getSessionDirectories() + const dir = state.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd() + try { + await stopSessionProcesses(this.connectionService.getClient(), sessionId, dir) + } catch (err) { + this.log("onCloseSession: client not available:", err) + } + state.removeSession(sessionId) + this.panel?.sessions.clearSessionDirectory(sessionId) this.pushState() this.log(`Closed session ${sessionId}`) return null diff --git a/packages/kilo-vscode/src/kilo-provider/background-process.ts b/packages/kilo-vscode/src/kilo-provider/background-process.ts new file mode 100644 index 00000000000..1e59a3a8baf --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/background-process.ts @@ -0,0 +1,12 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export async function stopSessionProcesses( + client: KiloClient | null, + sessionID: string, + directory: string, +): Promise { + if (!client) return + await client.backgroundProcess + .stopSession({ sessionID, directory }) + .catch((err: unknown) => console.warn("[Kilo New] KiloProvider: Failed to stop background processes:", err)) +} diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 4d9b555a12b..c4f0fc8039c 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.3.7", + "version": "7.3.8", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts new file mode 100644 index 00000000000..61292540013 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, mock } from "bun:test" + +const { AgentManagerProvider } = await import("../../src/agent-manager/AgentManagerProvider") + +type Manager = { + connectionService: { getClient: () => unknown } + panel: { + sessions: { + getSessionDirectories: () => ReadonlyMap + clearSessionDirectory: (id: string) => void + } + } + getStateManager: () => unknown + getRoot: () => string + pushState: () => void + log: (...args: unknown[]) => void + onCloseSession: (sessionId: string) => Promise +} + +function createManager(options?: { dir?: string; panelDir?: string }) { + const stopped: unknown[] = [] + const cleared: string[] = [] + const removed: string[] = [] + const client = { + backgroundProcess: { + stopSession: mock(async (params: unknown) => { + stopped.push(params) + return { data: {} } + }), + }, + } + const state = { + directoryFor: mock((sessionId: string) => (sessionId === "s1" ? options?.dir : undefined)), + removeSession: mock((sessionId: string) => { + removed.push(sessionId) + }), + } + const manager = Object.create(AgentManagerProvider.prototype) as Manager + manager.connectionService = { getClient: () => client } + manager.panel = { + sessions: { + getSessionDirectories: () => new Map(options?.panelDir ? [["s1", options.panelDir]] : []), + clearSessionDirectory: (id) => cleared.push(id), + }, + } + manager.getStateManager = () => state + manager.getRoot = () => "/repo" + manager.pushState = mock(() => undefined) + manager.log = mock(() => undefined) + + return { manager, stopped, cleared, removed } +} + +describe("AgentManagerProvider closeSession", () => { + it("stops background processes in the worktree directory before closing", async () => { + const { manager, stopped, cleared, removed } = createManager({ dir: "/repo/worktree" }) + + await manager.onCloseSession("s1") + + expect(stopped).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }]) + expect(removed).toEqual(["s1"]) + expect(cleared).toEqual(["s1"]) + }) + + it("falls back to session provider directory mappings", async () => { + const { manager, stopped } = createManager({ panelDir: "/repo/panel-worktree" }) + + await manager.onCloseSession("s1") + + expect(stopped).toEqual([{ sessionID: "s1", directory: "/repo/panel-worktree" }]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/background-process.test.ts b/packages/kilo-vscode/tests/unit/background-process.test.ts new file mode 100644 index 00000000000..0990f0417c0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/background-process.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, mock } from "bun:test" +import { stopSessionProcesses } from "../../src/kilo-provider/background-process" + +const warn = console.warn + +afterEach(() => { + console.warn = warn +}) + +describe("stopSessionProcesses", () => { + it("stops all background processes for a session in the provided directory", async () => { + const calls: unknown[] = [] + const client = { + backgroundProcess: { + stopSession: mock(async (params: unknown) => { + calls.push(params) + return { data: {} } + }), + }, + } + + await stopSessionProcesses(client as never, "s1", "/repo/worktree") + + expect(calls).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }]) + }) + + it("logs stop failures without throwing", async () => { + const warnings: unknown[][] = [] + console.warn = (...args: unknown[]) => { + warnings.push(args) + } + const err = new Error("stop failed") + const client = { + backgroundProcess: { + stopSession: mock(async () => { + throw err + }), + }, + } + + await stopSessionProcesses(client as never, "s1", "/repo") + + expect(warnings).toHaveLength(1) + expect(warnings[0]?.[1]).toBe(err) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 0556b71b2d2..7bde1f4ad91 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -41,13 +41,20 @@ function createClient(options?: { messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }> messagesData?: unknown[] deleteDeferred?: Deferred + sessionData?: unknown + sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }> }) { const calls: { before?: string; limit?: number }[] = [] + const stopped: { sessionID: string; directory?: string }[] = [] return { calls, + stopped, session: { list: async () => ({ data: [] }), - get: async () => ({ data: null }), + get: async (params: { sessionID: string; directory?: string }) => { + if (options?.sessionGet) return options.sessionGet(params) + return { data: options?.sessionData ?? null } + }, status: async () => ({ data: {} }), messages: async (params: { before?: string; limit?: number }) => { calls.push({ before: params.before, limit: params.limit }) @@ -59,6 +66,12 @@ function createClient(options?: { return { data: {} } }, }, + backgroundProcess: { + stopSession: async (params: { sessionID: string; directory?: string }) => { + stopped.push(params) + return { data: {} } + }, + }, provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) }, app: { agents: async () => ({ data: [] }) }, config: { get: async () => ({ data: {} }) }, @@ -97,7 +110,11 @@ function createConnection(client: ReturnType) { type ProviderInternals = { connectionState: State webview: { postMessage: (message: unknown) => Promise } | null + currentSession: { id: string; directory?: string } | null + contextSessionID: string | undefined + sessionDirectories: Map trackedSessionIds: Set + stopCurrentSessionProcesses: (next?: string) => void handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -117,6 +134,90 @@ function makeProvider(client: ReturnType) { } describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { + it("stops background processes for the previous session when switching sessions", async () => { + const client = createClient({ + sessionData: { id: "s2", directory: "/repo/worktree", time: { created: 1, updated: 1 } }, + }) + const { internal } = makeProvider(client) + internal.currentSession = { id: "s1", directory: "/repo/old" } + + await internal.handleLoadMessages("s2") + + expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/old" }]) + }) + + it("does not stop background processes twice for focus-mode reconcile", async () => { + const client = createClient({ messagesData: [mkMessage("m1", "user", 1)] }) + const { internal } = makeProvider(client) + internal.currentSession = { id: "s1", directory: "/repo/old" } + + await internal.handleLoadMessages("s2", { mode: "focus" }) + + expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/old" }]) + }) + + it("ignores stale focus refreshes after switching sessions", async () => { + const s1 = defer<{ data: unknown }>() + const s2 = defer<{ data: unknown }>() + const client = createClient({ + sessionGet: async (params) => { + if (params.sessionID === "s1") return s1.promise + if (params.sessionID === "s2") return s2.promise + return { data: null } + }, + }) + const { internal } = makeProvider(client) + internal.currentSession = { id: "s1", directory: "/repo/old" } + internal.trackedSessionIds.add("s1") + + await internal.handleLoadMessages("s1", { mode: "focus" }) + const load = internal.handleLoadMessages("s2") + s2.resolve({ data: { id: "s2", directory: "/repo/new", time: { created: 2, updated: 2 } } }) + await load + await Promise.resolve() + expect(internal.currentSession?.id).toBe("s2") + + s1.resolve({ data: { id: "s1", directory: "/repo/old", time: { created: 1, updated: 1 } } }) + await Promise.resolve() + + expect(internal.currentSession?.id).toBe("s2") + expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/old" }]) + }) + + it("stops each synchronously selected session during rapid switches", async () => { + const messages = defer<{ data: unknown[]; response: { headers: Headers } }>() + const client = createClient({ messagesDeferred: messages }) + const { internal } = makeProvider(client) + internal.currentSession = { id: "s1", directory: "/repo/s1" } + internal.contextSessionID = "s1" + internal.sessionDirectories.set("s2", "/repo/s2") + + const s2 = internal.handleLoadMessages("s2") + const s3 = internal.handleLoadMessages("s3") + + expect(client.stopped).toEqual([ + { sessionID: "s1", directory: "/repo/s1" }, + { sessionID: "s2", directory: "/repo/s2" }, + ]) + + messages.resolve(mkResult([])) + await Promise.all([s2, s3]) + }) + + it("stops the selected visible session when clearSession runs with stale currentSession", async () => { + const client = createClient() + const { internal } = makeProvider(client) + internal.currentSession = { id: "s1", directory: "/repo/s1" } + internal.contextSessionID = "s2" + internal.sessionDirectories.set("s2", "/repo/s2") + + internal.stopCurrentSessionProcesses() + internal.contextSessionID = undefined + internal.currentSession = null + + expect(client.stopped).toEqual([{ sessionID: "s2", directory: "/repo/s2" }]) + }) + it("refetches the tail page on focus-mode reselection and posts a reconcile snapshot", async () => { // Regression: switching to an already-loaded session sent mode: "focus" // which only refreshed session metadata and status — not messages. If @@ -184,6 +285,19 @@ describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", ) expect(loaded).toEqual([]) + expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo" }]) + }) +}) + +describe("KiloProvider.handleDeleteSession / background processes", () => { + it("stops session background processes in the session directory before deletion", async () => { + const client = createClient() + const { internal } = makeProvider(client) + internal.sessionDirectories.set("s1", "/repo/worktree") + + await internal.handleDeleteSession("s1") + + expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }]) }) }) diff --git a/packages/kilo-vscode/tests/unit/session-utils.test.ts b/packages/kilo-vscode/tests/unit/session-utils.test.ts index e308a576e3d..ff453af8a1f 100644 --- a/packages/kilo-vscode/tests/unit/session-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/session-utils.test.ts @@ -3,6 +3,7 @@ import { computeStatus, calcTotalCost, calcContextUsage, + calcTokenUsage, buildFamilyCosts, buildFamilyParents, buildFamilyLabels, @@ -141,6 +142,35 @@ describe("calcContextUsage", () => { }) }) +describe("calcTokenUsage", () => { + it("sums assistant message input, output, and cache read tokens", () => { + const result = calcTokenUsage([ + { role: "assistant", tokens: { input: 100, output: 40, reasoning: 8, cache: { read: 10, write: 5 } } }, + { role: "assistant", tokens: { input: 25, output: 15, cache: { read: 7, write: 3 } } }, + ]) + + expect(result).toEqual({ input: 125, output: 55, cached: 17 }) + }) + + it("ignores user messages, missing tokens, reasoning tokens, and cache writes", () => { + const result = calcTokenUsage([ + { role: "user", tokens: { input: 999, output: 999, cache: { read: 999, write: 999 } } }, + { role: "assistant" }, + { role: "assistant", tokens: { input: 10, output: 4, reasoning: 30, cache: { read: 2, write: 20 } } }, + ]) + + expect(result).toEqual({ input: 10, output: 4, cached: 2 }) + }) + + it("returns undefined when there are no displayed token counts", () => { + const result = calcTokenUsage([ + { role: "assistant", tokens: { input: 0, output: 0, reasoning: 12, cache: { read: 0, write: 6 } } }, + ]) + + expect(result).toBeUndefined() + }) +}) + // ── Cost breakdown helpers ────────────────────────────────────────────── function msg(id: string, role: string, cost?: number) { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index 97405e2cd26..6360660cce3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -14,7 +14,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Icon } from "@kilocode/kilo-ui/icon" import { Checkbox } from "@kilocode/kilo-ui/checkbox" import { useSession } from "../../context/session" -import { collapseCostBreakdown } from "../../context/session-utils" +import { calcTokenUsage, collapseCostBreakdown } from "../../context/session-utils" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { TaskTimeline } from "./TaskTimeline" @@ -66,18 +66,7 @@ export const TaskHeader: Component = (props) => { return { tokens, pct } }) - // Token breakdown from the last assistant message — only return if at least one value is > 0 - const tokens = createMemo(() => { - const msgs = session.visibleMessages() - for (let i = msgs.length - 1; i >= 0; i--) { - const m = msgs[i] - if (m.role !== "assistant" || !m.tokens) continue - const tk = m.tokens - const has = tk.input > 0 || tk.output > 0 || (tk.cache?.write ?? 0) > 0 || (tk.cache?.read ?? 0) > 0 - if (has) return tk - } - return undefined - }) + const tokens = createMemo(() => calcTokenUsage(session.visibleMessages())) const hasTimeline = createMemo(() => { for (const m of session.visibleMessages()) { @@ -207,16 +196,10 @@ export const TaskHeader: Component = (props) => { {fmtNum(tk().output)} - 0}> - - - cache {fmtNum(tk().cache!.write)} - - - 0}> + 0}> - cache {fmtNum(tk().cache!.read)} + cache {fmtNum(tk().cached)} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx index 7c262d26e3d..c04c6cf870e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx @@ -7,14 +7,155 @@ * upstream tool registrations have run (i.e. after importing message-part). */ +import { createMemo, For, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { ToolRegistry } from "@kilocode/kilo-ui/message-part" +import { BasicTool } from "@kilocode/kilo-ui/basic-tool" +import { ToolRegistry, type ToolProps } from "@kilocode/kilo-ui/message-part" /** Tools that should be open by default in the VS Code sidebar. */ const DEFAULT_OPEN_TOOLS = ["bash"] const registered = new Set() +const TITLE: Record = { + start: "Start background process", + list: "List background processes", + status: "Check background process", + logs: "View background logs", + stop: "Stop background process", + restart: "Restart background process", +} +const STRUCTURED_ACTIONS = new Set(["start", "status", "stop", "restart"]) +const STRUCTURED_KEYS = new Set(["id", "status", "pid", "cwd", "command", "last_output"]) +const LABEL: Record = { + command: "Command", + id: "Process id", + last_output: "Last output", + pid: "PID", + status: "Status", + cwd: "Cwd", +} + +function text(value: unknown) { + if (typeof value === "string" && value.trim()) return value.trim() + if (typeof value === "number" || typeof value === "boolean") return String(value) + return undefined +} + +function action(input: Record) { + const value = text(input.action) + if (!value) return "status" + return value +} + +function ready(input: Record) { + const value = input.ready + if (!value || typeof value !== "object") return [] + const data = value as Record + return [text(data.port) ? ["Ports", text(data.port)!] : undefined].filter((item): item is [string, string] => !!item) +} + +function structured(raw: string | undefined, enabled: boolean) { + if (!enabled) return { rows: [], output: output(raw) } + + const lines = raw?.trimEnd().split("\n") ?? [] + const rows = lines.flatMap((line): [string, string][] => { + const match = line.match(/^([a-z_]+):\s*(.*)$/) + if (!match || !STRUCTURED_KEYS.has(match[1])) return [] + const text = match[2].trim() + if (!text) return [] + return [[match[1], text]] + }) + const rest = lines.filter((line) => { + const match = line.match(/^([a-z_]+):\s*(.*)$/) + return !match || !STRUCTURED_KEYS.has(match[1]) + }) + + return { + rows: rows.map((row): [string, string] => [LABEL[row[0]] ?? row[0], row[1]]), + output: output(rest.join("\n")), + } +} + +function find(rows: [string, string][], label: string) { + return rows.find((row) => row[0] === label)?.[1] +} + +function output(text?: string) { + const value = text?.trimEnd() + if (!value?.trim()) return undefined + return value +} + +function expanded(status?: string, open?: boolean) { + if (open !== undefined) return open + return status === "pending" || status === "running" || status === "completed" +} + +function BackgroundProcessTool(props: ToolProps) { + const act = createMemo(() => action(props.input)) + const title = createMemo(() => TITLE[act()] ?? "Background process") + const data = createMemo(() => structured(props.output, props.status === "completed" && STRUCTURED_ACTIONS.has(act()))) + const id = createMemo(() => find(data().rows, "Process id") ?? text(props.metadata.processID) ?? text(props.input.id)) + const status = createMemo(() => find(data().rows, "Status") ?? text(props.metadata.status)) + const command = createMemo(() => find(data().rows, "Command") ?? text(props.input.command)) + const cwd = createMemo(() => find(data().rows, "Cwd") ?? text(props.input.cwd)) + const rows = createMemo(() => + [ + command() ? ["Command", command()!] : undefined, + text(props.input.description) ? ["Description", text(props.input.description)!] : undefined, + id() ? ["Process id", id()!] : undefined, + status() ? ["Status", status()!] : undefined, + cwd() ? ["Cwd", cwd()!] : undefined, + !cwd() && text(props.input.workdir) ? ["Workdir", text(props.input.workdir)!] : undefined, + ...ready(props.input), + ...data().rows.filter((row) => !["Command", "Process id", "Status", "Cwd"].includes(row[0])), + ].filter((item): item is [string, string] => !!item), + ) + + return ( + + 0}> +
+ + {(row) => ( +
+ {row[0]} + {row[1]} +
+ )} +
+
+
+ + {(value) => ( +
+
{value()}
+
+ )} +
+
+ ) +} + export function registerVscodeToolOverrides() { + if (!registered.has("background_process")) { + ToolRegistry.register({ + name: "background_process", + render: BackgroundProcessTool, + }) + registered.add("background_process") + } + for (const name of DEFAULT_OPEN_TOOLS) { if (registered.has(name)) continue const upstream = ToolRegistry.render(name) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index 2544246964a..842cb16fa46 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -98,6 +98,35 @@ export function calcContextUsage( return { tokens: total, percentage } } +export type TokenUsageMessage = { + role: string + tokens?: { + input: number + output: number + reasoning?: number + cache?: { read: number; write: number } + } +} + +export function calcTokenUsage( + messages: TokenUsageMessage[], +): { input: number; output: number; cached: number } | undefined { + const total = messages.reduce( + (sum, m) => { + if (m.role !== "assistant" || !m.tokens) return sum + return { + input: sum.input + m.tokens.input, + output: sum.output + m.tokens.output, + cached: sum.cached + (m.tokens.cache?.read ?? 0), + } + }, + { input: 0, output: 0, cached: 0 }, + ) + + if (total.input > 0 || total.output > 0 || total.cached > 0) return total + return undefined +} + /** * Build a map of session ID → **own cost** for each session in the family * that has non-zero own cost. diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 30739bd3315..079067ade3e 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -147,6 +147,74 @@ const bashPending = { }, } +const backgroundStartPending: ToolPart = { + id: "part-background-start-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-background-start-001", + tool: "background_process", + state: { + status: "running", + input: { + action: "start", + command: "bun run dev --host 127.0.0.1", + description: "Dev server", + workdir: "/project/web", + ready: { port: 5173, pattern: "ready in", timeout: 30000 }, + }, + metadata: {}, + time: { start: now - 2500 }, + }, +} + +const backgroundStartCompleted: ToolPart = { + id: "part-background-start-002", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-background-start-002", + tool: "background_process", + state: { + status: "completed", + input: { + action: "start", + command: "bun run dev --host 127.0.0.1", + description: "Dev server", + workdir: "/project/web", + ready: { port: 5173, pattern: "ready in", timeout: 30000 }, + }, + output: [ + "id: bgp_01hv8devserver", + "status: ready", + "pid: 42817", + "cwd: /project/web", + "command: bun run dev --host 127.0.0.1", + "last_output: VITE v5.4.0 ready in 318 ms", + ].join("\n"), + title: "Started background process", + metadata: { processID: "bgp?", status: "ready" }, + time: { start: now - 2400, end: now - 1800 }, + }, +} + +const backgroundLogsCompleted: ToolPart = { + id: "part-background-logs-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-background-logs-001", + tool: "background_process", + state: { + status: "completed", + input: { action: "logs", id: "bgp_01hv8devserver" }, + output: ["VITE v5.4.0 ready in 318 ms", "Local: http://127.0.0.1:5173/"].join("\n"), + title: "Logs: Dev server", + metadata: { processID: "bgp_01hv8devserver", status: "ready" }, + time: { start: now - 1800, end: now - 1200 }, + }, +} + const textPart: TextPart = { id: "part-text-001", sessionID: SESSION_ID, @@ -544,6 +612,18 @@ export const ToolCards: Story = { }, } +export const BackgroundProcessToolCards: Story = { + name: "Tool Cards — background process", + render: () => { + const data = dataWith([backgroundStartPending, backgroundStartCompleted, backgroundLogsCompleted]) + return ( + + + + ) + }, +} + // --------------------------------------------------------------------------- // 5. Chat idle — prompt input placeholder // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css b/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css index 853b00cdc1a..2eefff2c787 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css +++ b/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css @@ -14,3 +14,40 @@ [data-component="tool-output"][data-scrollable]:has([data-component="task-tools"]) { max-height: 200px; } + +/* ============================================ + Background Process Tool + ============================================ */ + +[data-component="background-process-fields"] { + display: grid; + gap: 6px; + margin-bottom: 8px; + padding: 8px 12px; +} + +[data-slot="background-process-field"] { + display: grid; + grid-template-columns: minmax(72px, max-content) minmax(0, 1fr); + gap: 8px; + align-items: baseline; + font-size: var(--kilo-font-size-12); +} + +[data-slot="background-process-label"] { + color: var(--text-dimmed); +} + +[data-slot="background-process-value"] { + color: var(--text-base); + font-family: var(--font-mono); + overflow-wrap: anywhere; +} + +[data-slot="background-process-output"] { + margin: 0; + font: inherit; + font-family: var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 55ff157c3bd..3f2f0b3e783 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,13 @@ # @kilocode/cli +## 7.3.8 + +### Patch Changes + +- [#8403](https://github.com/Kilo-Org/kilocode/pull/8403) [`42844e5`](https://github.com/Kilo-Org/kilocode/commit/42844e505475650c16f92251421ad792c6429184) Thanks [@saschabuehrle](https://github.com/saschabuehrle)! - Accept `env` as an alias for `environment` in local MCP server configuration. Configurations using the more common `env` key (matching Docker, npm, and VS Code conventions) are now normalised on load instead of failing strict validation. + +- [#10495](https://github.com/Kilo-Org/kilocode/pull/10495) [`ae0fbe8`](https://github.com/Kilo-Org/kilocode/commit/ae0fbe89dc5859fcea3c5d1e459a77eb459a8f71) - Show recent and favorited models in provider-specific model lists. + ## 7.3.7 ### Patch Changes diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql new file mode 100644 index 00000000000..3bdf2b85e9c --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `event_sequence` ADD `owner_id` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json new file mode 100644 index 00000000000..4f6ebe00c0a --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -0,0 +1,1429 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "27114226-085b-421a-9a40-29b88747e29a", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 46aa448f4ac..3ff9a2ccf37 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.7", + "version": "7.3.8", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -83,7 +83,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", diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 2ccb09193b0..15c89f5bb7a 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -776,9 +776,9 @@ const scenarios: Scenario[] = [ })) .status(200), http - .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .post("/experimental/workspace/warp", "experimental.workspace.warp") .at((ctx) => ({ - path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + path: "/experimental/workspace/warp", headers: ctx.headers(), body: {}, })) diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md new file mode 100644 index 00000000000..746e658693d --- /dev/null +++ b/packages/opencode/specs/effect/errors.md @@ -0,0 +1,329 @@ +# Typed error migration + +Plan for moving `packages/opencode` from temporary defect/`NamedError` +compatibility toward typed Effect service errors and explicit HTTP error +contracts. + +## Goal + +- Expected service failures live on the Effect error channel. +- Service interfaces expose those failures in their return types. +- Domain errors are authored with Effect Schema so they are reusable by services, + tests, HTTP routes, tools, and OpenAPI generation. +- HTTP status codes and wire compatibility are handled at the HTTP boundary, not + inside service modules. +- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved + for defects, compatibility bridges, or final fallback behavior. + +## Current State + +- Many migrated services use Effect internally, but expected failures are still a + mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`, + `throw`, and `Effect.die(...)`. +- Some services already use `Schema.TaggedErrorClass`, for example `Account`, + `Auth`, `Permission`, `Question`, `Installation`, and parts of + `Workspace`. +- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a + few name-based cases, then emits the legacy `{ name, data }` JSON body. +- Effect `HttpApi` only knows how to encode errors that are declared on the + endpoint, group, or middleware. Undeclared expected errors become defects and + eventually fall through to generic HTTP handling. +- The temporary HttpApi error middleware catches defect-wrapped legacy errors to + preserve runtime behavior, but it is intentionally a bridge rather than the + final model. + +## End State + +Service modules own domain failures. + +```ts +export class SessionBusyError extends Schema.TaggedErrorClass()("SessionBusyError", { + sessionID: SessionID, + message: Schema.String, +}) {} + +export type Error = Storage.Error | SessionBusyError + +export interface Interface { + readonly get: (id: SessionID) => Effect.Effect +} +``` + +HTTP modules own transport mapping. + +```ts +const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { + return yield* session + .get(ctx.params.sessionID) + .pipe( + Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })), + ) +}) +``` + +HTTP-visible error schemas carry their own response status through Effect +HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the +equivalent declaration annotation, instead of maintaining a parallel status map. + +```ts +export class SessionNotFoundHttpError extends Schema.TaggedErrorClass()( + "SessionNotFoundHttpError", + { + sessionID: SessionID, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} +``` + +Endpoint definitions still declare which HTTP-visible error schemas can be +emitted. The status annotation is only used if the error is part of the endpoint, +group, or middleware error schema and the handler fails with that error on the +typed error channel. + +```ts +HttpApiEndpoint.get("get", SessionPaths.get, { + success: Session.Info, + error: [SessionNotFoundHttpError, SessionBusyHttpError], +}) +``` + +The service error and HTTP error may be the same class when the wire shape is a +deliberate public contract. They should be different classes when the service +error contains internals, low-level causes, retry hints, or anything that should +not be exposed to API clients. + +## Rules + +- Use `Schema.TaggedErrorClass` for new expected domain errors. +- Include `cause: Schema.optional(Schema.Defect)` only when preserving an + underlying unknown failure is useful for logs or callers. +- Export a domain-level error union from each service module, for example + `export type Error = NotFoundError | BusyError | Storage.Error`. +- Put expected errors in service method signatures, for example + `Effect.Effect`. +- Use `yield* new DomainError(...)` for direct early failures inside + `Effect.gen` / `Effect.fn`. +- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to + convert external exceptions into domain errors. +- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible + error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status + from the schema itself. +- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth, + provider, worktree, or busy-state failures. +- Do not use `catchDefect` to recover expected domain errors. If recovery is + needed, the upstream effect should fail with a typed error instead. +- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP + status codes, or route-specific error schemas. +- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is + the right transport abstraction, but prefer declared `HttpApi` errors for + normal JSON API endpoints. + +## HTTP Boundary Shape + +Create an HttpApi-local error module, likely +`src/server/routes/instance/httpapi/errors.ts`. + +That module should provide: + +- Legacy-compatible public schemas for `{ name, data }` error bodies that must + remain SDK-compatible during the Hono migration. +- Small constructors or mapping helpers for common API errors such as not found, + bad request, conflict, and unknown internal errors. +- Route-group-specific adapters only when they encode domain-specific public + data. +- A single place to document which public error shape is legacy-compatible and + which shape is new Effect-native API surface. + +Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close +to the handler or route group. + +```ts +const mapSessionError = (effect: Effect.Effect) => + effect.pipe( + Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })), + Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })), + ) +``` + +Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related +types only when their generated response body and SDK surface are intentionally +acceptable. Use a custom schema-backed error when clients need the legacy +`{ name, data }` body or a domain-specific error payload. + +## Migration Phases + +### 1. Stabilize The Bridge + +Keep the temporary HttpApi error middleware only as a compatibility bridge while +typed errors are introduced. + +- Add tests that prove the bridge catches legacy `NamedError` defects. +- Add tests that prove declared HttpApi errors still use the declared endpoint + contract. +- Stop returning stack traces in unknown HTTP `500` responses; log the full + `Cause.pretty(cause)` server-side instead. +- Add a comment or TODO that names this plan and states the bridge must shrink + as route groups migrate. + +### 2. Define The Shared HTTP Error Helpers + +Add the `httpapi/errors.ts` module before converting route groups. + +- Define a legacy `{ name, data }` body helper for SDK-compatible errors. +- Define `UnknownError` for generic internal failures with a safe public message. +- Define `BadRequestError` and `NotFoundError` equivalents only if the actual + wire body must match the legacy Hono SDK surface. +- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or + `{ httpApiStatus: code }`; do not keep a separate name-to-status table. +- Keep conversion helpers pure and small. They should not inspect `Cause` or + accept `unknown` unless they are final fallback helpers. + +### 3. Convert One Vertical Slice + +Start with session read routes because they already have local `mapNotFound` +logic and are heavily covered by existing HttpApi tests. + +- Convert `Session.BusyError` from a plain `Error` to a typed service error, or + add a typed wrapper while preserving the old constructor until callers are + migrated. +- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error + mapping. +- Add endpoint error schemas for the affected session endpoints. +- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`. +- Remove the migrated cases from the global compatibility middleware. + +### 4. Convert Legacy NamedError Domains + +Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in +small domain PRs. + +Priority order: + +1. `storage/storage.ts` and `storage/db.ts` not-found errors. +2. `worktree/index.ts` `Worktree*` errors. +3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors. +4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors. +5. Config and CLI-only errors after HTTP-facing domains are stable. + +For each domain: + +- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error + is primarily a service error. +- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire + shape must remain stable. +- Update service interface return types to include the new error union. +- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`. +- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`. +- Add service-level tests that assert the error tag and data, not just the HTTP + status. + +### 5. Declare HttpApi Errors Group By Group + +For each HttpApi group: + +- Inventory every service call and the typed errors it can return. +- Add only the public error schemas that endpoint can actually emit. +- Map service errors to HTTP errors in the handler file. +- Keep built-in `HttpApiError` only for generic request/validation failures where + the generated contract is accepted. +- Update `httpapi/public.ts` compatibility transforms only when the generated + spec cannot represent the desired source shape directly. +- Regenerate the SDK after OpenAPI-visible changes and verify the diff is + intentional. + +Suggested route order: + +1. `session` not-found and busy-state reads. +2. `experimental` worktree mutations. +3. `provider` auth and model selection errors. +4. `mcp` OAuth and connection errors. +5. Remaining route groups as Hono deletion work progresses. + +### 6. Remove Defect Recovery + +After enough route groups declare their expected errors: + +- Delete `catchDefect` recovery for domain errors. +- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from + HTTP middleware. +- Delete `NamedError` branches from the Effect HttpApi compatibility middleware + once no Effect route depends on them. +- Leave one final unknown-defect fallback that logs server-side and returns a + safe generic `500` body. + +## Inventory Checklist + +Use this checklist when touching a service or route group. + +- [ ] Does the service interface expose every expected failure in the Effect + error type? +- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state + failures modeled as typed errors instead of defects? +- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response + classes? +- [ ] Does the handler map each service error into a declared endpoint error? +- [ ] Does the endpoint `error` field include every public error the handler can + emit? +- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly + reviewed diff? +- [ ] Do tests cover both service-level error typing and HTTP-level status/body? +- [ ] Did the PR remove any now-unneeded case from the temporary compatibility + middleware? + +## Testing Requirements + +For service conversions: + +- Test the service method directly with `testEffect(...)`. +- Assert on `_tag` or class identity and the structured fields. +- Avoid testing by string-matching `Cause.pretty(...)`. + +For HttpApi conversions: + +- Add or update the focused `test/server/httpapi-*.test.ts` file. +- Assert status code, content type, and exact JSON body for declared public + errors. +- Add a regression test that the temporary middleware is no longer needed for the + migrated route. +- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is + deleted or the SDK contract intentionally changes. + +## Verification Commands + +Run from `packages/opencode` unless noted otherwise. + +```bash +bun run prettier --write +bunx oxlint +bun typecheck +bun run test -- test/server/httpapi-session.test.ts +``` + +Run SDK generation from the repo root when schemas or OpenAPI-visible errors +change. + +```bash +./packages/sdk/js/script/build.ts +``` + +## Open Questions + +- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a + more Effect-native tagged error body? +- Should storage not-found remain generic, or should callers map it to + domain-specific not-found errors before crossing service boundaries? +- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only + as a migration bridge for old `NamedError` contracts? +- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error + schemas in `httpapi/public.ts`? + +## Success Criteria + +- New service code no longer uses `die` for expected failures. +- A route reviewer can read an endpoint definition and see every public error it + can return. +- The temporary HttpApi error middleware shrinks over time instead of gaining new + name-based cases. +- Service tests prove domain error types without going through HTTP. +- HTTP tests prove status/body contracts without relying on defect recovery. diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index d68b67cfe79..12394a77df7 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -5,6 +5,8 @@ import { type AuthenticateRequest, type AuthMethod, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type ForkSessionRequest, type ForkSessionResponse, type InitializeRequest, @@ -51,9 +53,9 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, KiloClient, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ShellID } from "@/tool/shell/id" import { fetchDefaultModel } from "@kilocode/kilo-gateway" // kilocode_change +import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -569,6 +571,7 @@ export class Agent implements ACPAgent { image: true, }, sessionCapabilities: { + close: {}, fork: {}, list: {}, resume: {}, @@ -631,6 +634,9 @@ export class Agent implements ACPAgent { // Store ACP session state await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) const result = await this.loadSessionMode({ @@ -639,39 +645,6 @@ export class Agent implements ACPAgent { sessionId, }) - // Replay session history - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - const lastUser = messages?.findLast((m) => m.info.role === "user")?.info - if (lastUser?.role === "user") { - result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { - result.modes.currentModeId = lastUser.agent - this.sessionManager.setMode(sessionId, lastUser.agent) - } - result.configOptions = buildConfigOptions({ - currentModelId: result.models.currentModelId, - availableModels: result.models.availableModels, - modes: result.modes, - }) - } - for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -760,6 +733,9 @@ export class Agent implements ACPAgent { const sessionId = forked.id await this.sessionManager.load(sessionId, directory, mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) const mode = await this.loadSessionMode({ @@ -768,20 +744,6 @@ export class Agent implements ACPAgent { sessionId, }) - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -801,7 +763,7 @@ export class Agent implements ACPAgent { } } - async unstable_resumeSession(params: ResumeSessionRequest): Promise { + async resumeSession(params: ResumeSessionRequest): Promise { const directory = params.cwd const sessionId = params.sessionId const mcpServers = params.mcpServers ?? [] @@ -810,6 +772,9 @@ export class Agent implements ACPAgent { const model = await defaultModel(this.config, directory) await this.sessionManager.load(sessionId, directory, mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId, 20) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) const result = await this.loadSessionMode({ @@ -832,6 +797,27 @@ export class Agent implements ACPAgent { } } + async closeSession(params: CloseSessionRequest): Promise { + const session = this.sessionManager.remove(params.sessionId) + if (!session) return {} + + await this.sdk.session + .abort( + { + sessionID: params.sessionId, + directory: session.cwd, + }, + { throwOnError: true }, + ) + .catch((error) => { + log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) + }) + + this.permissionQueues.delete(params.sessionId) + log.info("close_session", { sessionId: params.sessionId }) + return {} + } + private async processMessage(message: SessionMessageResponse) { log.debug("process message", message) if (message.info.role !== "assistant" && message.info.role !== "user") return @@ -1163,23 +1149,26 @@ export class Agent implements ACPAgent { sessionId: string, ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { const availableModes = await this.loadAvailableModes(directory) - const currentModeId = - this.sessionManager.get(sessionId).modeId || - (await (async () => { - if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })()) + const storedModeId = this.sessionManager.get(sessionId).modeId + if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) { + return { availableModes, currentModeId: storedModeId } + } + + const currentModeId = await (async () => { + if (!availableModes.length) return undefined + const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + this.sessionManager.setMode(sessionId, resolvedModeId) + return resolvedModeId + })() return { availableModes, currentModeId } } private async loadSessionMode(params: LoadSessionRequest) { const directory = params.cwd - const model = await defaultModel(this.config, directory) const sessionId = params.sessionId + const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory)) const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) const entries = sortProvidersByName(providers) @@ -1188,7 +1177,7 @@ export class Agent implements ACPAgent { if (currentVariant && !availableVariants.includes(currentVariant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const availableModels = buildAvailableModels(entries) const modeState = await this.resolveModeState(directory, sessionId) const currentModeId = modeState.currentModeId const modes = currentModeId @@ -1271,13 +1260,15 @@ export class Agent implements ACPAgent { return { sessionId, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), availableModels, }, modes, configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), availableModels, + currentVariant, + availableVariants, modes, }), _meta: buildVariantMeta({ @@ -1300,6 +1291,24 @@ export class Agent implements ACPAgent { const entries = sortProvidersByName(providers) const availableVariants = modelVariantsFromProviders(entries, selection.model) + const modeState = await this.resolveModeState(session.cwd, session.id) + const modes = modeState.currentModeId + ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } + : undefined + + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "config_option_update", + configOptions: buildConfigOptions({ + currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false), + availableModels: buildAvailableModels(entries), + currentVariant: selection.variant, + availableVariants, + modes, + }), + }, + }) return { _meta: buildVariantMeta({ @@ -1331,6 +1340,14 @@ export class Agent implements ACPAgent { const selection = parseModelSelection(params.value, providers) this.sessionManager.setModel(session.id, selection.model) this.sessionManager.setVariant(session.id, selection.variant) + } else if (params.configId === "effort") { + if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string") + const current = session.model ?? (await defaultModel(this.config, session.cwd)) + const availableVariants = modelVariantsFromProviders(entries, current) + if (!availableVariants.includes(params.value)) { + throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` })) + } + this.sessionManager.setVariant(session.id, params.value) } else if (params.configId === "mode") { if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") const availableModes = await this.loadAvailableModes(session.cwd) @@ -1345,15 +1362,21 @@ export class Agent implements ACPAgent { const updatedSession = this.sessionManager.get(session.id) const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false) + const availableModels = buildAvailableModels(entries) const modeState = await this.resolveModeState(session.cwd, session.id) const modes = modeState.currentModeId ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } : undefined return { - configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), + configOptions: buildConfigOptions({ + currentModelId, + availableModels, + currentVariant: updatedSession.variant, + availableVariants, + modes, + }), } } @@ -1550,6 +1573,37 @@ export class Agent implements ACPAgent { { throwOnError: true }, ) } + + private async loadSessionMessages(directory: string, sessionId: string, limit?: number) { + return this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + limit, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + } + + private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) { + const lastUser = messages?.findLast((message) => message.info.role === "user")?.info + if (lastUser?.role !== "user") return + + this.sessionManager.setModel(sessionId, { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + }) + this.sessionManager.setVariant(sessionId, lastUser.model.variant) + if (lastUser.agent) { + this.sessionManager.setMode(sessionId, lastUser.agent) + } + } } function toToolKind(toolName: string): ToolKind { @@ -1634,6 +1688,9 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider if (specified && !providers.length) return specified // kilocode_change start + const lastUsed = await lastUsedModel(sdk, directory, providers) + if (lastUsed) return lastUsed + const kiloProvider = providers.find((p) => p.id === "kilo") if (kiloProvider) { const [best] = Provider.sort(Object.values(kiloProvider.models)) @@ -1657,7 +1714,7 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider if (specified) return specified - // kilocode_change start + // kilocode_change start - prefer kilo free-tier default before erroring. // Only fall back to the Kilo provider if it was present in the available // providers list. When teams configure enabled_providers to use only their // own models, this prevents silently routing requests to an external API. @@ -1667,8 +1724,39 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider const freeModel = await fetchDefaultModel() return { providerID: ProviderID.kilo, modelID: ModelID.make(freeModel) } } - throw new Error("no model available: no providers are configured and no default model is set") // kilocode_change end + throw new Error("No models available") +} + +async function lastUsedModel( + sdk: KiloClient, + directory: string, + providers: Array<{ id: string; models: Record }>, +): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> { + const session = await sdk.session + .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) + .then((x) => x.data?.[0]) + .catch((error) => { + log.error("failed to list sessions for default model", { error }) + return undefined + }) + if (!session) return + + const lastUser = await sdk.session + .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true }) + .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info) + .catch((error) => { + log.error("failed to load session messages for default model", { error, sessionID: session.id }) + return undefined + }) + if (lastUser?.role !== "user") return + + const provider = providers.find((entry) => entry.id === lastUser.model.providerID) + if (!provider?.models[lastUser.model.modelID]) return + return { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + } } function parseUri( @@ -1771,8 +1859,14 @@ function formatModelIdWithVariant( includeVariant: boolean, ) { const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || !variant || !availableVariants.includes(variant)) return base - return `${base}/${variant}` + if (!includeVariant || availableVariants.length === 0) return base + const selectedVariant = + variant && availableVariants.includes(variant) + ? variant + : availableVariants.includes(DEFAULT_VARIANT_VALUE) + ? DEFAULT_VARIANT_VALUE + : availableVariants[0] + return `${base}/${selectedVariant}` } function buildVariantMeta(input: { @@ -1824,6 +1918,8 @@ function parseModelSelection( function buildConfigOptions(input: { currentModelId: string availableModels: ModelOption[] + currentVariant?: string + availableVariants?: string[] modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined }): SessionConfigOption[] { const options: SessionConfigOption[] = [ @@ -1836,6 +1932,22 @@ function buildConfigOptions(input: { options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), }, ] + if (input.availableVariants?.length) { + options.push({ + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: + input.currentVariant && input.availableVariants.includes(input.currentVariant) + ? input.currentVariant + : input.availableVariants.includes(DEFAULT_VARIANT_VALUE) + ? DEFAULT_VARIANT_VALUE + : input.availableVariants[0], + options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })), + }) + } if (input.modes) { options.push({ id: "mode", @@ -1853,4 +1965,11 @@ function buildConfigOptions(input: { return options } +function formatVariantName(variant: string) { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" ") +} + export * as ACP from "./agent" diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index a20397c597e..5ccbc4d84fd 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -113,4 +113,10 @@ export class ACPSessionManager { this.sessions.set(sessionId, session) return session } + + remove(sessionId: string): ACPSessionState | undefined { + const session = this.sessions.get(sessionId) + this.sessions.delete(sessionId) + return session + } } diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index f264bb62d62..8497417ca4c 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -22,7 +22,7 @@ export const AcpCommand = effectCmd({ }, handler: Effect.fn("Cli.acp")(function* (args) { process.env.KILO_CLIENT = "acp" - const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) const sdk = createKiloClient({ diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 47e448cd57e..c7151a2125a 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -84,7 +84,7 @@ const AgentCreateCommand = effectCmd({ // Determine scope/path let targetPath: string if (cliPath) { - targetPath = path.join(cliPath, "agent") + targetPath = path.join(cliPath, "agents") } else { let scope: "global" | "project" = "global" if (project.vcs === "git") { @@ -108,7 +108,7 @@ const AgentCreateCommand = effectCmd({ } targetPath = path.join( scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".kilo"), // kilocode_change - "agent", + "agents", ) } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a41711936bc..f55ac978ea4 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -278,7 +278,7 @@ export const RunCommand = effectCmd({ }) .option("attach", { type: "string", - describe: "attach to a running opencode server (e.g., http://localhost:4096)", + describe: "attach to a running kilo server (e.g., http://localhost:4096)", // kilocode_change }) .option("password", { alias: ["p"], diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 1a2d50717a0..e8df3443d72 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -16,7 +16,7 @@ export const ServeCommand = effectCmd({ if (!Flag.KILO_SERVER_PASSWORD) { console.log("Warning: KILO_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`kilo server listening on http://${server.hostname}:${server.port}`) // kilocode_change diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 49e905b177e..f85d6f00c54 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -9,6 +9,7 @@ import { Locale } from "@/util/locale" import { Flag } from "@opencode-ai/core/flag/flag" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" +import { NotFoundError } from "@/storage/storage" import { EOL } from "os" import path from "path" import { which } from "../../util/which" @@ -59,9 +60,9 @@ export const SessionDeleteCommand = effectCmd({ handler: Effect.fn("Cli.session.delete")(function* (args) { const svc = yield* Session.Service const sessionID = SessionID.make(args.sessionID) - // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. - yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) - yield* svc.remove(sessionID) + yield* svc + .remove(sessionID) + .pipe(Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`))) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }), }) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4ec2172f197..a9e04cd57e7 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -67,20 +67,21 @@ import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" import { FormatError, FormatUnknownError } from "@/cli/error" -import { resetTerminalState } from "@/kilocode/cli/cmd/tui/util/terminal" // kilocode_change +import { kitty, resetTerminalState } from "@/kilocode/cli/cmd/tui/util/terminal" // kilocode_change import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { const mouseEnabled = !Flag.KILO_DISABLE_MOUSE && (_config.mouse ?? true) + const keyboard = kitty() // kilocode_change return { externalOutputMode: "passthrough", targetFps: 60, gatherStats: false, exitOnCtrlC: false, - useKittyKeyboard: {}, + ...(keyboard ? { useKittyKeyboard: {} } : {}), // kilocode_change autoFocus: false, openConsoleOnError: false, useMouse: mouseEnabled, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 659cfcfb465..848bfc6f8ad 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -135,11 +135,14 @@ export function DialogModel(props: { providerID?: string }) { }, })), filter((x) => { - // kilocode_change: always dedupe favorites/recents (upstream only did this when showSections was true) - if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) - return false - if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) - return false + // kilocode_change start - only dedupe favorites/recents when those sections are visible + if (showExtra()) { + if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) + return false + if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) + return false + } + // kilocode_change end return true }), sortBy( diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index d35dcd794f2..c79c1fcb5cd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -19,6 +19,60 @@ import { useConnected } from "./use-connected" const PROVIDER_PRIORITY: Record = KiloProvider.PROVIDER_PRIORITY // kilocode_change +const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" +const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ + +type ProviderOptionBase = { + title: string + value: string + description?: string + category: string +} + +type ProviderOption = + | (ProviderOptionBase & { + type: "provider" + providerID: string + }) + | (ProviderOptionBase & { + type: "custom" + }) + +export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] { + return [ + ...pipe( + list, + sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + map((provider) => ({ + type: "provider" as const, + title: provider.name, + value: provider.id, + providerID: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(API key)", + openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", + }[provider.id], + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", + })), + ), + { + type: "custom", + title: "Other", + value: CUSTOM_PROVIDER_OPTION_VALUE, + description: "Custom provider", + category: "Providers", + }, + ] +} + +export function normalizeCustomProviderID(value: string) { + const providerID = value.trim().replace(/^@ai-sdk\//, "") + if (!CUSTOM_PROVIDER_ID.test(providerID)) return + return providerID +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() @@ -26,31 +80,68 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + + async function promptCustomProviderID(): Promise { + const value = await DialogPrompt.show(dialog, "Other", { + placeholder: "Provider id", + description: () => ( + + This only stores a credential. Configure the provider in kilo.json to use it.{/* kilocode_change */} + + ), + }) + if (value === null) return + + const providerID = normalizeCustomProviderID(value) + if (providerID) return providerID + + toast.show({ + variant: "error", + message: + "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", + }) + return promptCustomProviderID() + } + const options = createMemo(() => { return pipe( - sync.data.provider_next.all, - sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + providerOptions(sync.data.provider_next.all), map((provider) => { - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) - const connected = sync.data.provider_next.connected.includes(provider.id) + if (provider.type === "custom") { + return { + title: provider.title, + value: provider.value, + description: provider.description, + category: provider.category, + async onSelect() { + const providerID = await promptCustomProviderID() + if (!providerID) return + return dialog.replace(() => ) + }, + } + } + + const providerID = provider.providerID + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) + const connected = sync.data.provider_next.connected.includes(providerID) // kilocode_change start const failed = sync.data.provider_next.failed ?? [] - const failedGutter = KiloProvider.renderGutter(provider.id, failed, theme) - const failedDesc = KiloProvider.failedDescription(provider.id, failed) - const baseDesc = KiloProvider.PROVIDER_DESCRIPTIONS[provider.id] + const failedGutter = KiloProvider.renderGutter(providerID, failed, theme) + const failedDesc = KiloProvider.failedDescription(providerID, failed) + const baseDesc = KiloProvider.PROVIDER_DESCRIPTIONS[providerID] // kilocode_change end return { - title: KiloProvider.PROVIDER_TITLES[provider.id] ?? provider.name, // kilocode_change - value: provider.id, - description: failedDesc ?? baseDesc, // kilocode_change + title: KiloProvider.PROVIDER_TITLES[providerID] ?? provider.title, // kilocode_change + value: provider.value, + description: failedDesc ?? baseDesc ?? provider.description, // kilocode_change footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + category: provider.category, gutter: failedGutter ?? (connected && onboarded() ? () => ✓ : undefined), // kilocode_change async onSelect() { if (consoleManaged) return - const methods = sync.data.provider_auth[provider.id] ?? [ + const methods = sync.data.provider_auth[providerID] ?? [ { type: "api", label: "API key", @@ -88,7 +179,7 @@ export function createDialogProviderOptions() { } const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, + providerID, method: index, inputs, }) @@ -102,18 +193,13 @@ export function createDialogProviderOptions() { } if (result.data?.method === "code") { dialog.replace(() => ( - + )) } if (result.data?.method === "auto") { // kilocode_change start const kilo = KiloProvider.renderAutoMethod({ - providerID: provider.id, + providerID, title: method.label, index, authorization: result.data!, @@ -127,7 +213,7 @@ export function createDialogProviderOptions() { // kilocode_change end dialog.replace(() => ( ( - + )) } }, @@ -266,11 +352,13 @@ interface ApiMethodProps { providerID: string title: string metadata?: Record + custom?: boolean } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() const sdk = useSDK() const sync = useSync() + const toast = useToast() const { theme } = useTheme() return ( @@ -290,6 +378,14 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() + if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) { + toast.show({ + variant: "info", + message: `Saved credential for ${props.providerID}. Configure it in kilo.json to use it.`, // kilocode_change + }) + dialog.clear() + return + } dialog.replace(() => ) }} /> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index f4c38ca89e8..43fa3663c3f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" @@ -10,16 +10,14 @@ import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import path from "path" // kilocode_change import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" - -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +import { WorkspaceLabel } from "./workspace-label" export function DialogSessionList() { const dialog = useDialog() @@ -67,26 +65,41 @@ export function DialogSessionList() { }) // kilocode_change end - function createWorkspace() { - dialog.replace(() => ( - - openWorkspaceSession({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - }) - } - /> - )) - } - function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) + const warp = async (selection: WorkspaceSelection) => { + const workspaceID = await (async () => { + if (selection.type === "none") return null + if (selection.type === "existing") return selection.workspaceID + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + const workspace = result?.data + if (!workspace) { + toast.show({ + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + await project.workspace.sync() + return workspace.id + })() + if (workspaceID === undefined) return + await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + sourceWorkspaceID: session.workspaceID, + workspaceID, + sessionID: session.id, + copyChanges: false, + done: list, + }) + } dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID, - sessionID: session.id, - done: list, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warp(selection) + }, + }) return false }} /> @@ -148,30 +154,17 @@ export function DialogSessionList() { .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - let workspaceStatus: WorkspaceStatus | null = null - if (x.workspaceID) { - workspaceStatus = project.workspace.status(x.workspaceID) || "error" - } - - let footer = "" + let footer: JSX.Element | string = "" if (Flag.KILO_EXPERIMENTAL_WORKSPACES) { if (x.workspaceID) { - let desc = "unknown" - if (workspace) { - desc = `${workspace.type}: ${workspace.name}` - } - - footer = ( - <> - {desc}{" "} - - ● - - + footer = workspace ? ( + + ) : ( + ) } } else { @@ -294,15 +287,6 @@ export function DialogSessionList() { }, }, // kilocode_change end - { - keybind: Keybind.parse("ctrl+w")[0], - title: "new workspace", - side: "right", - disabled: !Flag.KILO_EXPERIMENTAL_WORKSPACES, - onTrigger: () => { - createWorkspace() - }, - }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 713f429725e..17869a80323 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -1,14 +1,15 @@ -import { createKiloClient } from "@kilocode/sdk/v2" +import type { Workspace } from "@kilocode/sdk/v2" import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" import { createMemo, createSignal, onMount } from "solid-js" -import { setTimeout as sleep } from "node:timers/promises" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" +import { DialogAlert } from "../ui/dialog-alert" +import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" type Adapter = { type: string @@ -16,184 +17,290 @@ type Adapter = { description: string } -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { - return createKiloClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) +export type WorkspaceSelection = + | { + type: "none" + } + | { + type: "new" + workspaceType: string + workspaceName: string + } + | { + type: "existing" + workspaceID: string + workspaceType: string + workspaceName: string + } + +type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } +type ExistingWorkspaceSelectValue = { workspace: Workspace } + +export function recentConnectedWorkspaces(input: { + sessions: readonly { workspaceID?: string; time: { updated: number } }[] + get: (workspaceID: string) => WorkspaceInfo | undefined + status: (workspaceID: string) => string | undefined + limit?: number + omitWorkspaceID?: string +}) { + const workspaces = input.sessions + .toSorted((a, b) => b.time.updated - a.time.updated) + .flatMap((session) => { + const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined + return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] + }) + .filter((workspace) => workspace.id !== input.omitWorkspaceID) + .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) + const recent = workspaces.slice(0, input.limit ?? 3) + + return { recent, hasMore: recent.length < workspaces.length } } -export async function openWorkspaceSession(input: { - dialog: ReturnType - route: ReturnType +export function warpReminderText(dir: string) { + return `The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` +} + +async function loadWorkspaceAdapters(input: { sdk: ReturnType sync: ReturnType toast: ReturnType - workspaceID: string }) { - const client = scoped(input.sdk, input.sync, input.workspaceID) - - while (true) { - const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - - input.route.navigate({ - type: "session", - sessionID: result.data.id, - }) - input.dialog.clear() - return - } + const dir = input.sync.path.directory || input.sdk.directory + const url = new URL("/experimental/workspace/adapter", input.sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await input.sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (res) return res + input.toast.show({ + message: "Failed to load workspace adapters", + variant: "error", + }) } -export async function restoreWorkspaceSession(input: { +export async function openWorkspaceSelect(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { + input.dialog.clear() + const adapters = await loadWorkspaceAdapters(input) + if (!adapters) return + input.dialog.replace(() => ) +} + +export async function warpWorkspaceSession(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType project: ReturnType toast: ReturnType - workspaceID: string + sourceWorkspaceID?: string + workspaceID: string | null sessionID: string + copyChanges: boolean done?: () => void -}) { +}): Promise { const result = await input.sdk.client.experimental.workspace - .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) + .warp({ + id: input.workspaceID, + sessionID: input.sessionID, + copyChanges: input.copyChanges, + }) .catch(() => undefined) if (!result?.data) { + if (result?.error?.name === "VcsApplyError") { + await DialogAlert.show( + input.dialog, + "Unable to Warp Session", + "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.", + ) + return false + } + input.toast.show({ - message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, + message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) - return + return false } input.project.workspace.set(input.workspaceID) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]) + const dir = input.project.instance.directory() || input.sync.path.directory + if (dir) { + await input.sdk.client.session + .promptAsync({ + sessionID: input.sessionID, + workspace: input.workspaceID ?? undefined, + noReply: true, + parts: [ + { + type: "text", + text: warpReminderText(dir), + synthetic: true, + }, + ], + }) + .catch(() => undefined) + } - input.toast.show({ - message: "Session restored into the new workspace", - variant: "success", - }) - input.done?.() - if (input.done) return + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) + + if (input.done) { + input.done() + return true + } input.dialog.clear() + return true } -export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { +export async function confirmWorkspaceFileChanges(input: { + dialog: ReturnType + sdk: ReturnType + sourceWorkspaceID?: string +}) { + const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) + const fileChangeChoice = status?.data?.length + ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) + : "no" + if (!fileChangeChoice) return + return fileChangeChoice === "yes" +} + +export function DialogWorkspaceSelect(props: { + adapters?: Adapter[] + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const dialog = useDialog() - const sync = useSync() const project = useProject() + const route = useRoute() + const sync = useSync() const sdk = useSDK() const toast = useToast() - const [creating, setCreating] = createSignal() - const [adapters, setAdapters] = createSignal() + const [adapters, setAdapters] = createSignal(props.adapters) + const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) onMount(() => { dialog.setSize("medium") void (async () => { - const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adapter", sdk.url) - if (dir) url.searchParams.set("directory", dir) - const res = await sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (!res) { - toast.show({ - message: "Failed to load workspace adapters", - variant: "error", - }) - return - } + if (adapters()) return + const res = await loadWorkspaceAdapters({ sdk, sync, toast }) + if (!res) return setAdapters(res) })() }) - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } + const options = createMemo[]>(() => { const list = adapters() - if (!list) { - return [ - { - title: "Loading workspaces...", - value: "loading" as const, - description: "Fetching available workspace adapters", + if (!list) return [] + const { recent, hasMore } = recentConnectedWorkspaces({ + sessions: sync.data.session, + get: project.workspace.get, + status: project.workspace.status, + omitWorkspaceID: omittedWorkspaceID(), + }) + return [ + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "New workspace", + })), + { + title: "None", + value: { type: "none" as const }, + description: "Use the local project", + category: "Choose workspace", + }, + ...recent.map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { + type: "existing" as const, + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, }, - ] - } - return list.map((item) => ({ - title: item.name, - value: item.type, - description: item.description, - })) + category: "Choose workspace", + })), + ...(hasMore + ? [ + { + title: "View all workspaces", + value: { type: "existing-list" as const }, + description: "Choose from all workspaces", + category: "Choose workspace", + }, + ] + : []), + ] }) - const create = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => { - toast.show({ - message: "Creating workspace failed", - variant: "error", - }) - return undefined - }) - - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, - variant: "error", - }) - return - } - - await project.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - + if (!adapters()) return null return ( - + title="Warp" skipFilter={true} + renderFilter={false} options={options()} onSelect={(option) => { - if (option.value === "creating" || option.value === "loading") return - void create(option.value) + if (!option.value) return + if (option.value.type === "none") { + void props.onSelect(option.value) + return + } + if (option.value.type === "new") { + void props.onSelect(option.value) + return + } + if (option.value.type === "existing") { + void props.onSelect(option.value) + return + } + + dialog.replace(() => ( + + )) + }} + /> + ) +} + +function DialogExistingWorkspaceSelect(props: { + omitWorkspaceID?: string + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { + const project = useProject() + + const options = createMemo[]>(() => + project.workspace + .list() + .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .filter((workspace) => workspace.id !== props.omitWorkspaceID) + .map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { workspace }, + })), + ) + + return ( + + title="Existing Workspace" + options={options()} + onSelect={(option) => { + void props.onSelect({ + type: "existing", + workspaceID: option.value.workspace.id, + workspaceType: option.value.workspace.type, + workspaceName: option.value.workspace.name, + }) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx new file mode 100644 index 00000000000..5560ab9c6ec --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx @@ -0,0 +1,138 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import type { VcsFileStatus } from "@kilocode/sdk/v2" +import { createMemo, For } from "solid-js" +import { createStore } from "solid-js/store" +import { Locale } from "@/util/locale" +import { useTheme } from "../context/theme" +import { useTuiConfig } from "../context/tui-config" +import { useDialog, type DialogContext } from "../ui/dialog" +import { getScrollAcceleration } from "../util/scroll" + +const options = ["no", "yes"] as const + +export type WorkspaceFileChangesChoice = (typeof options)[number] + +function statusLabel(status: VcsFileStatus["status"]) { + if (status === "added") return "A" + if (status === "deleted") return "D" + return "M" +} + +function changeCountWidth(file: VcsFileStatus) { + // The "plus 2" is for spaces + return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2 +} + +export function DialogWorkspaceFileChanges(props: { + files: VcsFileStatus[] + onSelect: (choice: WorkspaceFileChangesChoice) => void +}) { + const dialog = useDialog() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice }) + const height = createMemo(() => Math.min(props.files.length, 8)) + const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0)) + + function confirm() { + props.onSelect(store.active) + dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + confirm() + return + } + if (evt.name === "left") { + evt.preventDefault() + evt.stopPropagation() + const index = options.indexOf(store.active) + setStore("active", options[Math.max(index - 1, 0)]) + return + } + if (evt.name === "right") { + evt.preventDefault() + evt.stopPropagation() + const index = options.indexOf(store.active) + setStore("active", options[Math.min(index + 1, options.length - 1)]) + } + }) + + return ( + + + + File Changes Found + + dialog.clear()}> + esc + + + + + {(item) => ( + + + + {statusLabel(item.status)} + + + {Locale.truncateLeft(item.file, fileNameWidth())} + + + + + {" "} + {item.additions ? +{item.additions} : null} + {item.deletions ? -{item.deletions} : null} + + + + )} + + + + + Do you want to apply these changes after warping? + + + + + {(item) => ( + { + setStore("active", item) + props.onSelect(item) + dialog.clear() + }} + > + {item} + + )} + + + + ) +} + +DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => { + return new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(undefined), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ed9e87ea0ea..4e00cef699b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" @@ -41,10 +42,17 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" -import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" +import { + confirmWorkspaceFileChanges, + openWorkspaceSelect, + warpWorkspaceSession, + type WorkspaceSelection, +} from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { KiloSessionTuiSync } from "@/kilocode/session/tui-sync" // kilocode_change +import { Flag } from "@opencode-ai/core/flag/flag" +import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" export type PromptProps = { sessionID?: string @@ -171,11 +179,99 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) - const [editorContextHover, setEditorContextHover] = createSignal(false) - let lastSubmittedEditorSelectionKey: string | undefined + const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() + const [workspaceSelection, setWorkspaceSelection] = createSignal() + const [workspaceCreating, setWorkspaceCreating] = createSignal(false) + const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) + const [warpNotice, setWarpNotice] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current()) + + function selectWorkspace(selection: WorkspaceSelection | undefined) { + setWorkspaceSelection(selection) + } + + function setCreatingWorkspace(creating: boolean) { + setWorkspaceCreating(creating) + } + + function showWarpNotice(name: string) { + setWarpNotice(`Warped to ${name}`) + setTimeout(() => setWarpNotice(undefined), 4000) + } + + async function createWorkspace(selection: Extract) { + setCreatingWorkspace(true) + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + if (result == undefined || result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + message: "Creating workspace failed", + variant: "error", + }) + return + } + + await project.workspace.sync() + const workspace = result.data + selectWorkspace({ + type: "existing", + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, + }) + setCreatingWorkspace(false) + return workspace + } + + async function warpSession(selection: WorkspaceSelection) { + if (!props.sessionID) { + selectWorkspace(selection) + dialog.clear() + if (selection.type === "new") void createWorkspace(selection) + return + } + const sourceWorkspaceID = project.workspace.current() + const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) + if (copyChanges === undefined) return + selectWorkspace(selection) + dialog.clear() + + const workspace = + selection.type === "none" + ? { id: null, name: "local project" } + : selection.type === "existing" + ? { id: selection.workspaceID, name: selection.workspaceName } + : await createWorkspace(selection) + if (!workspace) return + + const warped = await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + sourceWorkspaceID, + workspaceID: workspace.id, + sessionID: props.sessionID, + copyChanges, + }) + if (warped) showWarpNotice(workspace.name) + } + + createEffect(() => { + if (!workspaceCreating()) { + setWorkspaceCreatingDots(3) + return + } + const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) function promptModelWarning() { toast.show({ @@ -214,6 +310,7 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { + if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) @@ -497,6 +594,27 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "Warp", + description: "Change the workspace for the session", + value: "workspace.set", + category: "Session", + enabled: Flag.KILO_EXPERIMENTAL_WORKSPACES, + slash: { + name: "warp", + }, + onSelect: (dialog) => { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + }, + }, ] }) @@ -715,6 +833,8 @@ export function Prompt(props: PromptProps) { ]) async function submit() { + setWarpNotice(undefined) + // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. @@ -723,6 +843,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false + if (workspaceCreating()) return false if (autocomplete?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() @@ -745,21 +866,16 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID: nextWorkspaceID, - sessionID: props.sessionID!, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false }} /> )) @@ -769,8 +885,16 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { + const workspace = workspaceSelection() + const workspaceID = iife(() => { + if (!workspace) return defaultWorkspaceID() + if (workspace.type === "none") return undefined + if (workspace.type === "existing") return workspace.workspaceID + return undefined + }) + const res = await sdk.client.session.create({ - workspace: props.workspaceID, + workspace: workspaceID, agent: agent.name, model: { providerID: selectedModel.providerID, @@ -818,9 +942,8 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() - const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = - editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey + editorSelection && editor.labelState() === "pending" ? [ { id: PartID.ascending(), @@ -898,7 +1021,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - lastSubmittedEditorSelectionKey = currentEditorSelectionKey + if (editorParts.length > 0) editor.markSelectionSent() } toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit history.append({ @@ -914,13 +1037,15 @@ export function Prompt(props: PromptProps) { props.onSubmit?.() // temporary hack to make sure the message is sent - if (!props.sessionID) + if (!props.sessionID) { + if (editorParts.length > 0) editor.preserveSelectionFromNewSession() setTimeout(() => { route.navigate({ type: "session", sessionID, }) }, 50) + } input.clear() return true } @@ -1042,6 +1167,39 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) + const workspaceLabel = createMemo< + | { type: "new"; workspaceType: string } + | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } + | undefined + >(() => { + const selected = workspaceSelection() + if (!selected) { + const workspaceID = defaultWorkspaceID() + if (props.sessionID || !workspaceID) return + const workspace = project.workspace.get(workspaceID) + return { + type: "existing", + workspaceType: workspace?.type ?? "unknown", + workspaceName: workspace?.name ?? workspaceID, + status: project.workspace.status(workspaceID) ?? "error", + } + } + if (selected.type === "none") return + if (props.sessionID && !workspaceCreating()) return + if (selected.type === "new") { + return { + type: "new", + workspaceType: selected.workspaceType, + } + } + return { + type: "existing", + workspaceType: selected.workspaceType, + workspaceName: selected.workspaceName, + status: selected.type === "existing" ? "connected" : undefined, + } + }) + const spinnerDef = createMemo(() => { const agent = local.agent.current() const color = agent ? local.agent.color(agent.name ?? "") : theme.border // kilocode_change @@ -1318,7 +1476,7 @@ export function Prompt(props: PromptProps) { }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={theme.text} + cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} /> @@ -1394,86 +1552,124 @@ export function Prompt(props: PromptProps) { /> - }> - - - - [⋯]}> - - - - - {(() => { - const retry = createMemo(() => { - const s = status() - if (s.type !== "retry") return - return s - }) - const message = createMemo(() => { - const r = retry() - if (!r) return - if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) - return "gemini is way too hot right now" - if (r.message.length > 80) return r.message.slice(0, 80) + "..." - return r.message - }) - const isTruncated = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.length > 120 - }) - const [seconds, setSeconds] = createSignal(0) - onMount(() => { - const timer = setInterval(() => { - const next = retry()?.next - if (next) setSeconds(Math.round((next - Date.now()) / 1000)) - }, 1000) - - onCleanup(() => { - clearInterval(timer) + + + + + + [⋯]}> + + + + + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) + const message = createMemo(() => { + const r = retry() + if (!r) return + if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) + return "gemini is way too hot right now" + if (r.message.length > 80) return r.message.slice(0, 80) + "..." + return r.message + }) + const isTruncated = createMemo(() => { + const r = retry() + if (!r) return false + return r.message.length > 120 + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + const handleMessageClick = () => { + const r = retry() + if (!r) return + if (isTruncated()) { + void DialogAlert.show(dialog, "Retry Error", r.message) + } } - } - const retryText = () => { - const r = retry() - if (!r) return "" - const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" - const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const duration = formatDuration(seconds()) + const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } - return ( - - - {retryText()} - - - ) - })()} + return ( + + + {retryText()} + + + ) + })()} + + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - - - + + + {(notice) => ( + + {notice()} + + )} + + + {(workspace) => ( + + + + + + {(() => { + const item = workspace() + if (item.type === "new") { + if (workspaceCreating()) + return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}` + return ( + <> + Workspace (new {item.workspaceType}) + + ) + } + return ( + <> + Workspace {item.workspaceName} + + ) + })()} + + + )} + + {props.hint ?? } + {/* kilocode_change start - show "ctrl+c again to exit" hint */} @@ -1483,16 +1679,9 @@ export function Prompt(props: PromptProps) { {/* kilocode_change end */} - + {(file) => ( - setEditorContextHover(true)} - onMouseOut={() => setEditorContextHover(false)} - onMouseUp={dismissEditorContext} - > - {editorContextHover() ? `x ${file()}` : file()} - + {file()} )} diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx new file mode 100644 index 00000000000..efdbf715877 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx @@ -0,0 +1,19 @@ +import { useTheme } from "@tui/context/theme" + +export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + +export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { + const { theme } = useTheme() + const color = () => { + if (props.status === "connected") return theme.success + if (props.status === "error") return theme.error + return theme.textMuted + } + + return ( + <> + {props.icon ? ● : undefined} + {props.name} ({props.type}) + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index b6e4850d6ca..3f190f11c0a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({ type JsonRpcMessage = z.infer export type EditorSelection = z.infer export type EditorMention = z.infer +export type EditorLabelState = "pending" | "sent" | "none" type EditorServerInfo = z.infer type EditorConnection = { @@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const [store, setStore] = createStore<{ status: "disabled" | "connecting" | "connected" selection: EditorSelection | undefined + selectionSent: boolean server: EditorServerInfo | undefined }>({ status: "disabled", selection: undefined, + selectionSent: false, server: undefined, }) @@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create let zedSelection: Promise | undefined let lastZedSelectionKey: string | undefined let directory = process.cwd() + let preserveSelectionOnReconnect = false const pending = new Map() + const setSelection = (selection: EditorSelection | undefined) => { + const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection) + setStore("selection", selection) + if (changed) setStore("selectionSent", false) + } + + const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => { + if (preserveSelectionOnReconnect) { + preserveSelectionOnReconnect = false + return + } + if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined + setSelection(undefined) + } + const send = (payload: JsonRpcMessage) => { if (!socket || socket.readyState !== 1) return socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) @@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const key = editorSelectionKey(selection) if (key !== lastZedSelectionKey) { lastZedSelectionKey = key - setStore("selection", selection) + setSelection(selection) setStore("status", selection ? "connected" : "disabled") } }) @@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const selection = message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined if (selection?.success) { - setStore("selection", { ...selection.data, source: "websocket" }) + setSelection({ ...selection.data, source: "websocket" }) return } @@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const reconnectWithDirectory = (nextDirectory?: string) => { const resolved = nextDirectory || process.cwd() - if (directory === resolved) return + const sameDirectory = directory === resolved + clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory }) + if (sameDirectory) return directory = resolved attempt = 0 pending.clear() - lastZedSelectionKey = undefined if (reconnect) clearTimeout(reconnect) reconnect = undefined if (socket) { @@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create current.close() } setStore("status", "disabled") - setStore("selection", undefined) setStore("server", undefined) connect() } @@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create }, clearSelection() { lastZedSelectionKey = undefined - setStore("selection", undefined) + zedSelection = undefined + setSelection(undefined) + }, + preserveSelectionFromNewSession() { + preserveSelectionOnReconnect = true + }, + markSelectionSent() { + if (!store.selection) return + setStore("selectionSent", true) + }, + labelState(): EditorLabelState { + if (!store.selection) return "none" + return store.selectionSent ? "sent" : "pending" }, onMention(listener: (mention: EditorMention) => void) { mentionListeners.add(listener) @@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return store.server }, reconnect(directory?: string) { - setStore("selection", undefined) reconnectWithDirectory(directory) }, } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 6a456dbad3d..02df37eeb3e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -464,21 +464,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, } - // kilocode_change - validate configured agent model when agent changes createEffect(() => { // kilocode_change start - configured models resolve directly without persistence if (!model.ready) return const value = agent.current() - if (!value) return // guard against empty agent list during org switch - if (!value.model) return + if (!value?.model) return if (isModelValid(value.model)) return toast.show({ variant: "warning", message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, duration: 3000, }) - // kilocode_change end }) + // kilocode_change end const result = { model, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index d9592aa910b..faa3e0465a6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" function activeAssistant(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) if (index < 0) return const assistant = messages[index] return assistant?.type === "assistant" ? assistant : undefined } function activeCompaction(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "compaction") + const index = messages.findIndex((message) => message.type === "compaction") if (index < 0) return const compaction = messages[index] return compaction?.type === "compaction" ? compaction : undefined } function activeShell(messages: SessionMessage[], callID: string) { - const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) if (index < 0) return const shell = messages[index] return shell?.type === "shell" ? shell : undefined @@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( switch (event.type) { case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "user", text: event.properties.prompt.text, @@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( } case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "synthetic", sessionID: event.properties.sessionID, @@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "shell", callID: event.properties.callID, @@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( update(event.properties.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.push({ + draft.unshift({ id: event.id, type: "assistant", agent: event.properties.agent, @@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "compaction", reason: event.properties.reason, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 9eef7acf58e..fceb88585a9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -19,6 +19,7 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + BackgroundProcessInfo, // kilocode_change } from "@kilocode/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "@tui/context/project" @@ -77,6 +78,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ todo: { [sessionID: string]: Todo[] } + // kilocode_change start + background_process: { + [sessionID: string]: BackgroundProcessInfo[] + } + // kilocode_change end message: { [sessionID: string]: Message[] } @@ -119,6 +125,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ session_status: {}, session_diff: {}, todo: {}, + background_process: {}, // kilocode_change message: {}, part: {}, lsp: [], @@ -149,6 +156,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ delete draft.session_diff[sessionID] delete draft.session_status[sessionID] delete draft.todo[sessionID] + delete draft.background_process[sessionID] // kilocode_change delete draft.permission[sessionID] delete draft.question[sessionID] delete draft.suggestion[sessionID] @@ -168,6 +176,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // kilocode_change end const fullSyncedSessions = new Set() + const deleted = new Set() // kilocode_change let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { @@ -189,6 +198,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": + // kilocode_change start + deleted.clear() + setStore("background_process", {}) + // kilocode_change end void bootstrap() break case "permission.replied": { @@ -364,10 +377,53 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + // kilocode_change start case "session.status": { setStore("session_status", event.properties.sessionID, event.properties.status) break } + // kilocode_change end + + // kilocode_change start + case "background_process.updated": { + const info = event.properties.info + deleted.delete(info.id) + const list = store.background_process[info.sessionID] + if (!list) { + setStore("background_process", info.sessionID, [info]) + break + } + const result = Binary.search(list, info.id, (item) => item.id) + if (result.found) { + setStore("background_process", info.sessionID, result.index, reconcile(info)) + break + } + setStore( + "background_process", + info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, info) + }), + ) + break + } + + case "background_process.deleted": { + deleted.add(event.properties.processID) + const list = store.background_process[event.properties.sessionID] + if (!list) break + const result = Binary.search(list, event.properties.processID, (item) => item.id) + if (!result.found) break + setStore( + "background_process", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + // kilocode_change end // kilocode_change start case "message.updated": { @@ -515,6 +571,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const workspace = project.workspace.current() if (workspace !== syncedWorkspace) { fullSyncedSessions.clear() + // kilocode_change start + deleted.clear() + setStore("background_process", {}) + // kilocode_change end syncedWorkspace = workspace } const projectPromise = project.sync() @@ -588,9 +648,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))), + // kilocode_change start sdk.client.experimental.resource .list({ workspace }) .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), + // kilocode_change end sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))), // kilocode_change // kilocode_change start sdk.client.network.list().then((x) => { @@ -601,6 +663,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } setStore("network", reconcile(next)) }), + sdk.client.backgroundProcess.list({ workspace }).then((x) => { + const next: Record = {} + for (const item of x.data ?? []) { + if (!next[item.sessionID]) next[item.sessionID] = [] + next[item.sessionID].push(item) + } + for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id)) + setStore( + "background_process", + produce((draft) => { + for (const [sessionID, list] of Object.entries(next)) { + const items = new Map((draft[sessionID] ?? []).map((item) => [item.id, item])) + for (const item of list) { + if (deleted.has(item.id)) continue + const prev = items.get(item.id) + if (!prev || item.time.updated >= prev.time.updated) items.set(item.id, item) + } + const value = Array.from(items.values()).toSorted((a, b) => a.id.localeCompare(b.id)) + if (value.length === 0) delete draft[sessionID] + else draft[sessionID] = value + } + }), + ) + }), // kilocode_change end sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data ?? {})) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 3a2c9f52d24..78868d847ed 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -114,17 +114,17 @@ const TIPS = [ "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks", "Use plugins to send OS notifications when sessions complete", "Create a plugin to prevent OpenCode from reading sensitive files", - "Use {highlight}opencode run{/highlight} for non-interactive scripting", - "Use {highlight}opencode --continue{/highlight} to resume the last session", - "Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI", + "Use {highlight}kilo run{/highlight} for non-interactive scripting", // kilocode_change + "Use {highlight}kilo --continue{/highlight} to resume the last session", // kilocode_change + "Use {highlight}kilo run -f file.ts{/highlight} to attach files via CLI", // kilocode_change "Use {highlight}--format json{/highlight} for machine-readable output in scripts", - "Run {highlight}opencode serve{/highlight} for headless API access to OpenCode", - "Use {highlight}opencode run --attach{/highlight} to connect to a running server", - "Run {highlight}opencode upgrade{/highlight} to update to the latest version", - "Run {highlight}opencode auth list{/highlight} to see all configured providers", - "Run {highlight}opencode agent create{/highlight} for guided agent creation", + "Run {highlight}kilo serve{/highlight} for headless API access to Kilo", // kilocode_change + "Use {highlight}kilo run --attach{/highlight} to connect to a running server", // kilocode_change + "Run {highlight}kilo upgrade{/highlight} to update to the latest version", // kilocode_change + "Run {highlight}kilo auth list{/highlight} to see all configured providers", // kilocode_change + "Run {highlight}kilo agent create{/highlight} for guided agent creation", // kilocode_change "Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions", - "Run {highlight}opencode github install{/highlight} to set up the GitHub workflow", + "Run {highlight}kilo github install{/highlight} to set up the GitHub workflow", // kilocode_change "Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs", "Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews", 'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors', diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 5b36ebbb8b0..37a2fcdd73e 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { SyntaxStyle } from "@opentui/core" +import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const renderedMessages = createMemo(() => messages().toReversed()) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + const lastUserCreated = (index: number) => + renderedMessages() + .slice(0, index) + .findLast((message) => message.type === "user")?.time.created createEffect(() => { void sync.session.message.sync(props.sessionID) @@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { last={lastAssistant()?.id === message.id} syntax={syntax()} subtleSyntax={subtleSyntax()} + start={lastUserCreated(index())} /> - + <> @@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) { - - - } - > - {props.message.text} - - - - - {(file) => ( - - {file.mime} - {file.name ?? file.uri} - - )} - - - {(agent) => ( - - agent - {agent.name} - - )} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - ) -} - -function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { - const { theme } = useTheme() - return ( - - Synthetic {props.message.text} + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + ) } @@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) { } function CompactionMessage(props: { message: SessionMessageCompaction }) { - const { theme } = useTheme() + const { theme, syntax } = useTheme() return ( - {props.message.summary} + {(summary) => ( + + + + )} ) @@ -294,12 +284,13 @@ function AssistantMessage(props: { last: boolean syntax: SyntaxStyle subtleSyntax: SyntaxStyle + start?: number }) { const { theme } = useTheme() const local = useLocal() const duration = createMemo(() => { if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created + return props.message.time.completed - (props.start ?? props.message.time.created) }) const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" @@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta const { theme } = useTheme() return ( - + (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const complete = createMemo(() => !!props.complete) const denied = createMemo(() => { const message = error() if (!message) return false return ( message.includes("QuestionRejectedError") || message.includes("rejected permission") || + message.includes("specified a rule") || message.includes("user dismissed") ) }) + const fg = createMemo(() => { + if (error()) return theme.error + if (complete()) return theme.textMuted + return theme.text + }) + const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) return ( - - - - {props.children} - - - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - - - - - {error()} - + error() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (!error()) return + if (renderer.getSelection()?.getSelectedText()) return + setShowError((prev) => !prev) + }} + renderBefore={function () { + const el = this as BoxRenderable + const parent = el.parent + if (!parent) return + const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.id.startsWith("text")) setMargin(1) + }} + > + + + + + + + + {props.icon} + + + + + ~ + + + + + + + + + + {props.children} + + + + + {props.pending} + + + + + + + {error()} + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index cdbbf40e75c..2e2ac7bb615 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -159,6 +159,11 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { todo(sessionID) { return sync.data.todo[sessionID] ?? [] }, + // kilocode_change start + processes(sessionID) { + return sync.data.background_process[sessionID] ?? [] + }, + // kilocode_change end messages(sessionID) { return sync.data.message[sessionID] ?? [] }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index bae9f132048..b4e469e5517 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -5,6 +5,7 @@ import HomeNews from "@/kilocode/plugins/home-news" import HomeOnboarding from "@/kilocode/plugins/home-onboarding" import KiloHomeFooter from "@/kilocode/plugins/home-footer" import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer" +import KiloSidebarBackgroundProcesses from "@/kilocode/plugins/sidebar-background-processes" import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" // kilocode_change end @@ -29,6 +30,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ HomeOnboarding, // kilocode_change KiloHomeFooter, // kilocode_change KiloSidebarFooter, // kilocode_change + KiloSidebarBackgroundProcesses, // kilocode_change KiloSidebarPr, // kilocode_change KiloSidebarUsage, // kilocode_change HomeFooter, diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 4c1cd1babd1..43a52082beb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createEffect, createSignal } from "solid-js" +import { createEffect, createSignal, onMount } from "solid-js" import { Logo } from "../component/logo" import { useProject } from "../context/project" import { useSync } from "../context/sync" @@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route" import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" +import { useEditorContext } from "@tui/context/editor" let once = false const placeholder = { @@ -24,8 +25,13 @@ export function Home() { const [ref, setRef] = createSignal() const args = useArgs() const local = useLocal() + const editor = useEditorContext() let sent = false + onMount(() => { + editor.clearSelection() + }) + const bind = (r: PromptRef | undefined) => { setRef(r) promptRef.set(r) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 796ce2c3efb..21b0ca4bcb8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -3,6 +3,7 @@ import { createContext, createEffect, createMemo, + onCleanup, // kilocode_change createSignal, For, Match, @@ -25,6 +26,7 @@ import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, import { Prompt, type PromptRef } from "@tui/component/prompt" // kilocode_change start import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" +import * as Log from "@opencode-ai/core/util/log" // kilocode_change end import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" @@ -43,7 +45,10 @@ import type { WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" -import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change +// kilocode_change start +import type { BackgroundProcessTool } from "@/kilocode/tool/background-process" +import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" +// kilocode_change end import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useEditorContext } from "@tui/context/editor" @@ -270,6 +275,44 @@ export function Session() { const sdk = useSDK() const editor = useEditorContext() + // kilocode_change start - background processes are scoped to the visible session + function processGroup(sessionID: string) { + const info = sync.session.get(sessionID) + return info?.parentID ?? info?.id ?? sessionID + } + + function processSessions(sessionID: string) { + const group = processGroup(sessionID) + const ids = new Set([sessionID, group]) + for (const item of sync.data.session) { + if (item.id === group || item.parentID === group) ids.add(item.id) + } + return Array.from(ids) + } + + function stopProcesses(sessionID: string) { + const workspace = project.workspace.current() + for (const id of processSessions(sessionID)) { + void sdk.client.backgroundProcess.stopSession({ sessionID: id, workspace }).catch((err) => { + Log.Default.warn("failed to stop session background processes", { sessionID: id, err }) + }) + } + } + + let processSessionID = route.sessionID + createEffect(() => { + const next = route.sessionID + if (processSessionID === next) return + const prev = processSessionID + processSessionID = next + if (processGroup(prev) === processGroup(next)) return + stopProcesses(prev) + }) + onCleanup(() => { + stopProcesses(processSessionID) + }) + // kilocode_change end + createEffect(() => { const sessionID = route.sessionID void (async () => { @@ -1707,6 +1750,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess {/* kilocode_change start */} + + + @@ -2120,6 +2166,51 @@ function WebSearch(props: ToolProps) { } // kilocode_change start +function BackgroundProcess(props: ToolProps) { + const sync = useSync() + const running = createMemo(() => props.part.state.status === "running") + const cmd = createMemo(() => (typeof props.input.command === "string" ? props.input.command : "")) + const action = createMemo(() => props.input.action ?? "start") + const desc = createMemo(() => props.input.description || cmd() || props.input.id || "background process") + const dir = createMemo(() => { + const raw = props.input.workdir + if (!raw || raw === ".") return undefined + const base = sync.path.directory + if (!base) return normalizePath(raw) + const abs = path.resolve(base, raw) + if (abs === base) return undefined + return normalizePath(abs) + }) + const status = createMemo(() => { + if (typeof props.metadata.status === "string") return props.metadata.status + if (typeof props.metadata.count === "number") return `${props.metadata.count} running` + return undefined + }) + const title = createMemo(() => { + if (action() === "list") return "List background processes" + if (action() === "logs") return `View background logs: ${desc()}` + if (action() === "status") return `Check background process: ${desc()}` + if (action() === "stop") return `Stop background process: ${desc()}` + if (action() === "restart") return `Restart background process: ${desc()}` + return `Start background process: ${desc()}` + }) + + return ( + + {title()} + in {dir()} + · $ {cmd()} + ({status()}) + + ) +} + function SemanticSearch(props: ToolProps) { const meta = createMemo(() => props.metadata as { results?: { length: number }[] }) const args = createMemo(() => props.input as { query?: string; path?: string }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 4f9ec7f14e5..22edf17bcbc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -309,9 +309,20 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } if (permission === ShellID.ToolID) { - const title = - typeof data.description === "string" && data.description ? data.description : "Shell command" - const command = normalizeUrls(typeof data.command === "string" ? data.command : "") // kilocode_change + // kilocode_change start + const meta = props.request.metadata ?? {} + const desc = + typeof data.description === "string" && data.description + ? data.description + : typeof meta.description === "string" && meta.description + ? meta.description + : undefined + const bg = meta.backgroundProcess === true + const title = bg ? `Start background process${desc ? `: ${desc}` : ""}` : (desc ?? "Shell command") + const command = normalizeUrls( + typeof data.command === "string" ? data.command : typeof meta.command === "string" ? meta.command : "", + ) + // kilocode_change end return { icon: "#", title, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 7adc4c1db14..0f9214092eb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" +import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const project = useProject() @@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) - const workspaceStatus = () => { + const workspace = () => { const workspaceID = session()?.workspaceID - if (!workspaceID) return "error" - return project.workspace.status(workspaceID) ?? "error" - } - const workspaceLabel = () => { - const workspaceID = session()?.workspaceID - if (!workspaceID) return "unknown" - const info = project.workspace.get(workspaceID) - if (!info) return "unknown" - return `${info.type}: ${info.name}` + if (!workspaceID) return + return project.workspace.get(workspaceID) } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - ●{" "} - {workspaceLabel()} + } + > + {(item) => ( + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 4d68c443089..ef7d4bd3bbd 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,6 +23,7 @@ export interface DialogSelectProps { onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean + renderFilter?: boolean keybind?: { keybind?: Keybind.Info title: string @@ -81,7 +82,7 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable const filtered = createMemo(() => { - if (props.skipFilter) return props.options.filter((x) => x.disabled !== true) + if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() const options = pipe( props.options, @@ -250,30 +251,32 @@ export function DialogSelect(props: DialogSelectProps) { esc - - { - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} - ref={(r) => { - input = r - input.traits = { status: "FILTER" } - setTimeout(() => { - if (!input) return - if (input.isDestroyed) return - input.focus() - }, 1) - }} - placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} - /> - + + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + input.traits = { status: "FILTER" } + setTimeout(() => { + if (!input) return + if (input.isDestroyed) return + input.focus() + }, 1) + }} + placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} + /> + + 0} diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index d146093bfd0..7772c48b4ec 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -41,7 +41,7 @@ export const WebCommand = effectCmd({ if (!Flag.KILO_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! KILO_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) @@ -73,7 +73,7 @@ export const WebCommand = effectCmd({ } // Open localhost in browser - open(localhostUrl.toString()).catch(() => {}) + open(localhostUrl).catch(() => {}) } else { const displayUrl = server.url.toString() UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index b936abd192a..31495f4cc72 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,6 +1,6 @@ import type { Argv, InferredOptionTypes } from "yargs" import { Config } from "@/config/config" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" const options = { port: { @@ -36,10 +36,10 @@ export type NetworkOptions = InferredOptionTypes export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } -export async function resolveNetworkOptions(args: NetworkOptions) { - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) +export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) { + const config = yield* Config.Service.use((cfg) => cfg.getGlobal()) return resolveNetworkOptionsNoConfig(args, config) -} +}) export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) { const portExplicitlySet = process.argv.includes("--port") diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 4a49be541ee..1d238027394 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -5,9 +5,7 @@ import { logo as glyphs } from "./logo" import { tui } from "../kilocode/cli/logo" // kilocode_change // kilocode_change start -const wordmark = [ - ...tui(), -] +const wordmark = [...tui()] // kilocode_change end export const CancelledError = NamedError.create("UICancelledError", z.void()) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6fb98d5b113..e2cdab1f400 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -96,6 +96,36 @@ export type Warning = z.infer const { caught: caughtWarning } = KilocodeConfig // kilocode_change end +async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) { + if (!isRecord(input.value) || typeof input.value.url !== "string") return + + const url = await ConfigVariable.substitute({ + text: input.value.url, + type: "virtual", + dir: input.dir, + source: input.source, + }) + const headers = isRecord(input.value.headers) + ? Object.fromEntries( + await Promise.all( + Object.entries(input.value.headers) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(async ([key, value]) => [ + key, + await ConfigVariable.substitute({ + text: value, + type: "virtual", + dir: input.dir, + source: input.source, + }), + ]), + ), + ) + : undefined + + return { url, headers } +} + async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -140,7 +170,7 @@ export const Info = Schema.Struct({ }), logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }), server: Schema.optional(ConfigServer.Server).annotate({ - description: "Server configuration for opencode serve and web commands", + description: "Server configuration for the kilo serve command", // kilocode_change }), command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({ description: "Command configuration, see https://opencode.ai/docs/commands", @@ -634,42 +664,56 @@ export const layer = Layer.effect( for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") - const source = `${url}/.well-known/opencode` - process.env[value.key] = value.token - log.debug("fetching remote config", { url: source }) + const source = `${url}/.well-known/opencode` // kilocode_change // kilocode_change start - warn instead of fail on wellknown errors - const next = yield* Effect.tryPromise({ - try: async () => { - const response = await fetch(source) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) - } - const wellknown = (await response.json()) as { config?: Record } - const remoteConfig = wellknown.config ?? {} - if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" - return remoteConfig - }, - catch: (err) => err, - }).pipe( - Effect.flatMap((remoteConfig) => - loadConfig(JSON.stringify(remoteConfig), { - dir: path.dirname(source), - source, + yield* Effect.gen(function* () { + process.env[value.key] = value.token + log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) + const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) + if (!response.ok) { + throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + } + const wellknown = (yield* Effect.promise(() => response.json())) as { + config?: Record + remote_config?: unknown + } + const remote = yield* Effect.promise(() => + substituteWellKnownRemoteConfig({ + value: wellknown.remote_config, + dir: url, + source: `${url}/.well-known/opencode`, }), - ), - Effect.tap(() => Effect.sync(() => log.debug("loaded remote config from well-known", { url }))), + ) + const fetchedConfig = remote + ? ((yield* Effect.promise(async () => { + log.debug("fetching remote config", { url: remote.url }) + const response = await fetch(remote.url, { headers: remote.headers }) + if (!response.ok) + throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`) + const data = await response.json() + return isRecord(data) && isRecord(data.config) ? data.config : data + })) as Record) + : {} + const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) + if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change + const next = yield* loadConfig(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, + }) + yield* merge(source, next, "global") + log.debug("loaded remote config from well-known", { url }) + }).pipe( Effect.catch((err: unknown) => { caughtWarning(warnings, source, err) log.warn("skipped remote config due to error", { url, err }) - return Effect.succeed({} as Info) + return Effect.void }), Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) log.warn("skipped remote config due to error", { url, err }) - return Effect.succeed({} as Info) + return Effect.void }), ) - yield* merge(source, next, "global") // kilocode_change end } } diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fc31ba356fa..f72b7c0ab41 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,8 +1,8 @@ -import { Schema } from "effect" +import { Schema, SchemaGetter } from "effect" // kilocode_change import { zod } from "@/util/effect-zod" import { PositiveInt, withStatics } from "@/util/schema" -export const Local = Schema.Struct({ +const LocalCanonical = Schema.Struct({ // kilocode_change type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), command: Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Command and arguments to run the MCP server", @@ -17,9 +17,34 @@ export const Local = Schema.Struct({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) + +// kilocode_change start - accept `env` as an alias for `environment` +// The input schema admits either key and the transform normalises to the +// canonical `environment` field before validation downstream. +const LocalInput = Schema.Struct({ + type: Schema.Literal("local"), + command: Schema.mutable(Schema.Array(Schema.String)), + environment: Schema.optional(Schema.Record(Schema.String, Schema.String)), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + enabled: Schema.optional(Schema.Boolean), + timeout: Schema.optional(PositiveInt), +}) + +const normalizeLocal = (input: Schema.Schema.Type): Schema.Schema.Type => { + const { env, environment, ...rest } = input + return { ...rest, environment: environment ?? env } +} + +export const Local = LocalInput.pipe( + Schema.decodeTo(LocalCanonical, { + decode: SchemaGetter.transform(normalizeLocal), + encode: SchemaGetter.passthrough({ strict: false }), + }), +) .annotate({ identifier: "McpLocalConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Local = Schema.Schema.Type +// kilocode_change end export const OAuth = Schema.Struct({ clientId: Schema.optional(Schema.String).annotate({ diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index a821f133770..ed0aa77d608 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,10 +1,11 @@ -import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" +import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -17,17 +18,22 @@ import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdapter } from "./adapters" -import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" +import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" +import { Vcs } from "@/project/vcs" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" export const Info = WorkspaceInfoSchema export type Info = WorkspaceInfo @@ -38,13 +44,6 @@ export const ConnectionStatus = Schema.Struct({ }) export type ConnectionStatus = Schema.Schema.Type -const Restore = Schema.Struct({ - workspaceID: WorkspaceID, - sessionID: SessionID, - total: NonNegativeInt, - step: NonNegativeInt, -}) - export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -58,7 +57,6 @@ export const Event = { message: Schema.String, }), ), - Restore: BusEvent.define("workspace.restore", Restore), Status: BusEvent.define("workspace.status", ConnectionStatus), } @@ -84,15 +82,16 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectID, - extra: Info.fields.extra, + extra: Schema.optional(Info.fields.extra), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const SessionRestoreInput = Schema.Struct({ - workspaceID: WorkspaceID, +export const SessionWarpInput = Schema.Struct({ + workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, + copyChanges: Schema.optional(Schema.Boolean), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) -export type SessionRestoreInput = Schema.Schema.Type +export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { message: Schema.String, @@ -116,8 +115,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( - "WorkspaceSessionRestoreHttpError", +export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionWarpHttpError", { message: Schema.String, workspaceID: WorkspaceID, @@ -138,17 +137,18 @@ export class SyncAbortedError extends Schema.TaggedErrorClass( }) {} type CreateError = Auth.AuthError -type SessionRestoreError = +type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError - | SessionRestoreHttpError + | SessionWarpHttpError + | Vcs.PatchApplyError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError> + readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect @@ -169,8 +169,10 @@ export const layer = Layer.effect( Effect.gen(function* () { const auth = yield* Auth.Service const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service + const vcs = yield* Vcs.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -259,6 +261,66 @@ export const layer = Layer.effect( ) }) + const runInWorkspace = (input: { + workspaceID?: WorkspaceID + local: () => Effect.Effect + remote: (input: { + workspace: Info + target: Extract + }) => HttpClientRequest.HttpClientRequest + fallback: A + response?: "json" | "text" + }) => + Effect.gen(function* () { + if (!input.workspaceID) return yield* input.local() + + const workspace = yield* get(input.workspaceID) + if (!workspace) return input.fallback + + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace)) + + if (target.type === "local") { + const store = yield* InstanceStore.Service + return yield* store.provide({ directory: target.directory }, input.local()) + } + + const response = yield* http.execute(input.remote({ workspace, target })).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("workspace target request failed", { + workspaceID: workspace.id, + error: errorData(error), + }) + }), + ), + ) + if (!response) return input.fallback + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + log.warn("workspace target request failed", { + workspaceID: workspace.id, + status: response.status, + body, + }) + return input.fallback + } + + const body = input.response === "text" ? response.text : response.json + return yield* body.pipe( + Effect.map((result) => result as A), + Effect.catch((error) => + Effect.sync(() => { + log.warn("workspace target response decode failed", { + workspaceID: workspace.id, + error: errorData(error), + }) + return input.fallback + }), + ), + ) + }) + const syncHistory = Effect.fn("Workspace.syncHistory")(function* ( space: Info, url: URL | string, @@ -461,7 +523,7 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null }), + adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), ) const info: Info = { @@ -518,29 +580,123 @@ export const layer = Layer.effect( return info }) - const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { return yield* Effect.gen(function* () { - log.info("session restore requested", { + log.info("session warp requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, }) - const space = yield* get(input.workspaceID) + const current = yield* db((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) { + const adapter = getAdapter(previous.projectID, previous.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + + if (target.type === "remote") { + yield* syncHistory(previous, target.url, target.headers).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("session warp final source sync failed", { + workspaceID: previous.id, + sessionID: input.sessionID, + error: errorData(error), + }) + }), + ), + ) + } else { + yield* prompt.cancel(input.sessionID) + } + + // "claim" this session so any future events coming from + // the old workspace are ignored + SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + } + } + + const sourcePatch = + input.copyChanges && current?.workspaceID + ? yield* runInWorkspace({ + workspaceID: current?.workspaceID ?? undefined, + local: () => vcs.diffRaw(), + remote: ({ target }) => + HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { + headers: new Headers(target.headers), + }), + fallback: "", + response: "text", + }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + : "" + + if (sourcePatch) { + // Attempt to apply the file changes to the new workspace. + // We intentionally do first so if it fails we don't warp + // the session. + yield* runInWorkspace({ + workspaceID: input.workspaceID ?? undefined, + local: () => vcs.apply({ patch: sourcePatch }), + remote: ({ target }) => + HttpClientRequest.post(route(target.url, "/vcs/apply"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ patch: sourcePatch }), + }), + fallback: { applied: false }, + }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + } + + if (input.workspaceID === null) { + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }), + ) + + log.info("session warp complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + target: "local", + }) + return + } + + const workspaceID = input.workspaceID + const space = yield* get(workspaceID) if (!space) return yield* new WorkspaceNotFoundError({ - message: `Workspace not found: ${input.workspaceID}`, - workspaceID: input.workspaceID, + message: `Workspace not found: ${workspaceID}`, + workspaceID, }) const adapter = getAdapter(space.projectID, space.type) const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { + if (target.type === "local") { + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + log.info("session warp complete", { workspaceID: input.workspaceID, - }, - }) + sessionID: input.sessionID, + target: target.directory, + }) + return + } const rows = yield* db((db) => db @@ -562,130 +718,95 @@ export const layer = Layer.effect( sessionID: input.sessionID, }) - const size = 10 - // TODO: look into using effect APIs to process this in chunks - const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => - rows.slice(i * size, (i + 1) * size), - ) - const total = sets.length + const batches = Iterable.chunksOf(rows, 10) + const total = Iterable.size(batches) - log.info("session restore prepared", { + log.info("session warp prepared", { workspaceID: input.workspaceID, sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + target: String(route(target.url, "/sync/replay")), events: rows.length, batches: total, first: rows[0]?.seq, last: rows.at(-1)?.seq, }) - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, - }), - ) - - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - }) - - if (target.type === "local") { - yield* sync.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const res = yield* http.execute( - HttpClientRequest.post(url, { - headers: new Headers(target.headers), - body: HttpBody.jsonUnsafe({ - directory: space.directory ?? "", - events, + yield* Effect.forEach( + batches, + (events, i) => + Effect.gen(function* () { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), }), - }), - ) + ) - if (res.status < 200 || res.status >= 300) { - const body = yield* res.text - log.error("session restore batch failed", { + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + + log.info("session warp batch posted", { workspaceID: input.workspaceID, sessionID: input.sessionID, step: i + 1, total, - status: res.status, - body, + status: response.status, }) - return yield* new SessionRestoreHttpError({ - message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: res.status, - body, - }) - } - - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) - } - - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, }), - ) + { discard: true }, + ) + + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/steal"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp steal failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) } - log.info("session restore complete", { + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, batches: total, }) - - return { total } }).pipe( Effect.tapError((err) => Effect.sync(() => - log.error("session restore failed", { + log.error("session warp failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, error: errorData(err), @@ -715,9 +836,19 @@ export const layer = Layer.effect( const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) { const sessions = yield* db((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), + db + .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, id)) + .all(), + ) + const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id)) + yield* Effect.forEach( + sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)), + (sessionInfo) => + session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)), + { discard: true }, ) - yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true }) const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return @@ -814,7 +945,7 @@ export const layer = Layer.effect( return Service.of({ create, - sessionRestore, + sessionWarp, list, get, remove, @@ -830,6 +961,9 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 7c122e3501c..a61eb7be29a 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -91,6 +91,9 @@ export const layer = Layer.effect( cwd: dir, env: item.environment, extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", }), ) .pipe( diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index e279b07cb0c..ddf1ca8c366 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -69,6 +69,7 @@ export interface Options { readonly cwd: string readonly env?: Record readonly maxOutputBytes?: number + readonly stdin?: ChildProcess.CommandInput } export interface Interface { @@ -86,6 +87,7 @@ export interface Interface { readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly statUntracked: (cwd: string, file: string) => Effect.Effect + readonly applyPatch: (cwd: string, patch: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -102,6 +104,8 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const encoder = new TextEncoder() + const stdin = (text: string) => Stream.make(encoder.encode(text)) const run = Effect.fn("Git.run")( function* (args: string[], opts: Options) { @@ -109,7 +113,7 @@ export const layer = Layer.effect( cwd: opts.cwd, env: opts.env, extendEnv: true, - stdin: "ignore", + stdin: opts.stdin ?? "ignore", stdout: "pipe", stderr: "pipe", }) @@ -317,9 +321,13 @@ export const layer = Layer.effect( cwd, maxOutputBytes: 4096, }) + if (result.truncated) return - const parts = result.text().split("\t") + const text = result.text() + + const parts = text.split("\t") if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) return { @@ -329,6 +337,10 @@ export const layer = Layer.effect( } satisfies Stat }) + const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) { + return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) + }) + return Service.of({ run, branch, @@ -344,6 +356,7 @@ export const layer = Layer.effect( patchAll, patchUntracked, statUntracked, + applyPatch, }) }), ) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 57c5f7f24ab..bc0baceb820 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -14,6 +14,7 @@ const prefixes = { tool: "tool", workspace: "wrk", entry: "ent", + account: "act", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts new file mode 100644 index 00000000000..b347d67e92c --- /dev/null +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -0,0 +1,577 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" +import { Instance, type InstanceContext } from "@/project/instance" +import { SessionID } from "@/session/schema" +import { Shell } from "@/shell/shell" +import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@/util/effect-zod" +import * as Log from "@opencode-ai/core/util/log" +import { spawn, type ChildProcess } from "child_process" +import { Context, Effect, Layer, Schema, Types } from "effect" +import net from "net" +import path from "path" +import z from "zod" +import * as Ports from "./ports" + +export namespace BackgroundProcess { + const log = Log.create({ service: "background-process" }) + const MAX = 200 * 1024 + const KILL_MS = 3_000 + const READY_MS = 30_000 + const PUBLISH_MS = 500 + const PORT_MS = 2_000 + + const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("bgp") }).pipe( + Schema.brand("BackgroundProcessID"), + ) + export type ID = typeof idSchema.Type + export const ID = idSchema.pipe( + withStatics((schema: typeof idSchema) => ({ + ascending: (id?: string) => { + if (id && !id.startsWith("bgp")) throw new Error(`Background process ID must start with bgp: ${id}`) + return schema.make(id ?? Identifier.create("bgp", "ascending")) + }, + zod: zod(schema), + })), + ) + + export const Status = Schema.Literals(["starting", "running", "ready", "exited", "failed", "stopping", "stopped"]) + export type Status = Schema.Schema.Type + + export const Ready = Schema.Struct({ + pattern: optionalOmitUndefined(Schema.String).annotate({ + description: "Regular expression matched against output to mark the process ready", + }), + port: optionalOmitUndefined(PositiveInt).annotate({ + description: "Local TCP port to probe until accepting connections", + }), + timeout: optionalOmitUndefined(PositiveInt).annotate({ + description: "Milliseconds to wait for readiness before returning the process as running", + }), + }) + .annotate({ identifier: "BackgroundProcessReady" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Ready = Types.DeepMutable> + + export const Info = Schema.Struct({ + id: ID, + sessionID: SessionID, + pid: optionalOmitUndefined(PositiveInt), + command: Schema.String, + cwd: Schema.String, + description: optionalOmitUndefined(Schema.String), + ports: Schema.mutable(Schema.Array(PositiveInt)), + status: Status, + ready: Schema.Boolean, + exitCode: optionalOmitUndefined(Schema.NullOr(NonNegativeInt)), + signal: optionalOmitUndefined(Schema.NullOr(Schema.String)), + output: Schema.String, + time: Schema.Struct({ + started: NonNegativeInt, + updated: NonNegativeInt, + ended: optionalOmitUndefined(NonNegativeInt), + }), + }) + .annotate({ identifier: "BackgroundProcessInfo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Info = Types.DeepMutable> + + export const StartInput = Schema.Struct({ + sessionID: SessionID, + command: Schema.String.annotate({ description: "Command to run in the configured shell" }), + cwd: optionalOmitUndefined(Schema.String).annotate({ + description: "Working directory. Defaults to the project directory", + }), + description: optionalOmitUndefined(Schema.String).annotate({ description: "Short human readable process label" }), + ready: optionalOmitUndefined(Ready), + }) + .annotate({ identifier: "BackgroundProcessStartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type StartInput = Types.DeepMutable> + + export const Logs = Schema.Struct({ + id: ID, + sessionID: SessionID, + output: Schema.String, + }) + .annotate({ identifier: "BackgroundProcessLogs" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Logs = Types.DeepMutable> + + export const Event = { + Updated: BusEvent.define( + "background_process.updated", + Schema.Struct({ + info: Info, + }), + ), + Deleted: BusEvent.define( + "background_process.deleted", + Schema.Struct({ + sessionID: SessionID, + processID: ID, + }), + ), + } + + type Active = { + ctx: InstanceContext + info: Info + proc: ChildProcess + start: StartInput + pattern?: RegExp + resolve?: (ready: boolean) => void + notify?: ReturnType + poll?: ReturnType + scan?: Promise + disposed?: boolean + } + + type State = { + ctx: InstanceContext + dir: string + processes: Map + } + + class StateService extends Context.Service Effect.Effect }>()( + "@kilocode/BackgroundProcess.State", + ) {} + + function clone(info: Info): Info { + return { + ...info, + ports: [...info.ports], + time: { ...info.time }, + } + } + + function terminal(status: Status) { + return status === "exited" || status === "failed" || status === "stopped" + } + + function clamp(text: string) { + const buf = Buffer.from(text, "utf-8") + if (buf.length <= MAX) return text + let start = buf.length - MAX + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + return buf.subarray(start).toString("utf-8") + } + + function same(a: number[], b: number[]) { + return a.length === b.length && a.every((port, index) => port === b[index]) + } + + async function refresh(active: Active) { + const pid = active.proc.pid + if (!pid || terminal(active.info.status)) { + const changed = active.info.ports.length > 0 + active.info.ports = [] + return changed + } + const fallback = active.info.ready && active.start.ready?.port ? [active.start.ready.port] : [] + const next = Array.from(new Set([...(await Ports.list(pid)), ...fallback])).toSorted((a, b) => a - b) + if (same(active.info.ports, next)) return false + active.info.ports = next + active.info.time.updated = Date.now() + return true + } + + function emit(active: Active) { + Instance.restore(active.ctx, () => { + void Bus.publish(Event.Updated, { info: clone(active.info) }).catch((err) => { + log.warn("failed to publish process update", { err, id: active.info.id }) + }) + }) + } + + function publish(active: Active) { + if (active.disposed) return + active.scan = (active.scan ?? refresh(active)) + .then(() => { + active.scan = undefined + if (active.disposed) return false + emit(active) + poll(active) + return false + }) + .catch((err) => { + active.scan = undefined + if (active.disposed) return false + log.debug("failed to refresh process ports", { err, id: active.info.id }) + emit(active) + poll(active) + return false + }) + } + + function poll(active: Active) { + if (active.disposed) return + if (terminal(active.info.status)) return + if (active.poll) return + active.poll = setTimeout(() => { + active.poll = undefined + if (active.disposed) return + if (terminal(active.info.status)) return + active.scan = (active.scan ?? refresh(active)) + .then((changed) => { + active.scan = undefined + if (active.disposed) return false + if (changed) emit(active) + poll(active) + return changed + }) + .catch((err) => { + active.scan = undefined + if (active.disposed) return false + log.debug("failed to refresh process ports", { err, id: active.info.id }) + poll(active) + return false + }) + }, PORT_MS) + } + + function schedule(active: Active) { + if (active.disposed) return + if (active.notify) return + active.notify = setTimeout(() => { + active.notify = undefined + publish(active) + }, PUBLISH_MS) + } + + function ready(active: Active) { + if (active.disposed) return + if (active.info.ready) return + active.info.ready = true + active.info.status = "ready" + active.info.time.updated = Date.now() + active.resolve?.(true) + active.resolve = undefined + publish(active) + } + + function append(active: Active, chunk: string) { + if (active.disposed) return + active.info.output = clamp(active.info.output + chunk) + active.info.time.updated = Date.now() + if (active.pattern?.test(active.info.output)) ready(active) + schedule(active) + } + + function exited(active: Active, code: number | null, signal: NodeJS.Signals | null) { + if (active.disposed) return + if (terminal(active.info.status)) return + if (active.notify) clearTimeout(active.notify) + if (active.poll) clearTimeout(active.poll) + active.notify = undefined + active.poll = undefined + if (code === null) delete active.info.exitCode + else active.info.exitCode = code + if (signal === null) delete active.info.signal + else active.info.signal = signal + active.info.ports = [] + active.info.ready = active.info.ready && code === 0 + active.info.status = active.info.status === "stopping" ? "stopped" : code === 0 ? "exited" : "failed" + active.info.time.updated = Date.now() + active.info.time.ended = active.info.time.updated + active.resolve?.(false) + active.resolve = undefined + publish(active) + } + + function failed(active: Active, err: unknown) { + if (active.disposed) return + append(active, `\n${err instanceof Error ? err.message : String(err)}\n`) + exited(active, 1, null) + } + + function pattern(input?: string) { + if (!input) return + try { + return new RegExp(input) + } catch (err) { + throw new Error(`Invalid ready pattern: ${err instanceof Error ? err.message : String(err)}`) + } + } + + function connected(port: number) { + return new Promise((resolve) => { + const socket = net.createConnection({ port, host: "127.0.0.1" }) + const done = (ok: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolve(ok) + } + socket.setTimeout(500) + socket.once("connect", () => done(true)) + socket.once("error", () => done(false)) + socket.once("timeout", () => done(false)) + }) + } + + async function wait(active: Active, input: Ready) { + if (!input.pattern && !input.port) return false + if (input.pattern && active.pattern?.test(active.info.output)) { + ready(active) + return true + } + return new Promise((resolve) => { + const timeout = setTimeout(() => { + if (active.info.status === "starting") { + active.info.status = "running" + active.info.time.updated = Date.now() + publish(active) + } + active.resolve = undefined + resolve(false) + }, input.timeout ?? READY_MS) + active.resolve = (ok) => { + clearTimeout(timeout) + resolve(ok) + } + const poll = async () => { + if (!input.port) return + while (!terminal(active.info.status) && !active.info.ready && active.resolve) { + if (await connected(input.port)) { + ready(active) + return + } + await Bun.sleep(250) + } + } + void poll().catch((err) => { + log.warn("port readiness check failed", { err, id: active.info.id, port: input.port }) + }) + }) + } + + function env() { + const result: NodeJS.ProcessEnv = { + ...process.env, + TERM: "dumb", + } + delete result.KILO_SERVER_PASSWORD + delete result.KILO_SERVER_USERNAME + return result + } + + function stopped(proc: ChildProcess) { + return proc.exitCode !== null || proc.signalCode !== null + } + + function code(err: unknown) { + if (!err || typeof err !== "object" || !("code" in err)) return + const value = (err as { code?: unknown }).code + return typeof value === "string" ? value : undefined + } + + function group(pid: number) { + try { + process.kill(-pid, 0) + return true + } catch (err) { + if (code(err) === "ESRCH") return false + log.debug("failed to probe process group", { err, pid }) + return true + } + } + + function waitExit(proc: ChildProcess, ms: number) { + if (stopped(proc)) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done() { + clearTimeout(timer) + proc.off("exit", done) + proc.off("error", done) + resolve() + } + proc.once("exit", done) + proc.once("error", done) + }) + } + + async function kill(active: Active) { + const pid = active.proc.pid + if (!pid || stopped(active.proc)) return + if (process.platform === "win32") { + await new Promise((resolve) => { + const child = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { + stdio: "ignore", + windowsHide: true, + }) + child.once("exit", () => resolve()) + child.once("error", () => resolve()) + }) + return + } + try { + process.kill(-pid, "SIGTERM") + } catch (err) { + log.warn("failed to terminate process group", { err, pid }) + active.proc.kill("SIGTERM") + } + await waitExit(active.proc, KILL_MS) + if (stopped(active.proc) && !group(pid)) return + try { + process.kill(-pid, "SIGKILL") + } catch (err) { + log.warn("failed to kill process group", { err, pid }) + active.proc.kill("SIGKILL") + } + } + + async function terminate(state: State, active: Active, opts?: { remove?: boolean; silent?: boolean }) { + if (!terminal(active.info.status)) { + active.info.status = "stopping" + active.info.time.updated = Date.now() + if (!opts?.silent) publish(active) + await kill(active) + if (!terminal(active.info.status)) exited(active, active.proc.exitCode, active.proc.signalCode) + } + if (!opts?.remove) return + active.disposed = true + state.processes.delete(active.info.id) + if (active.notify) clearTimeout(active.notify) + if (active.poll) clearTimeout(active.poll) + active.resolve?.(false) + active.resolve = undefined + if (opts.silent) return + await Instance.restore(active.ctx, () => + Bus.publish(Event.Deleted, { sessionID: active.info.sessionID, processID: active.info.id }).catch((err) => { + log.warn("failed to publish process deletion", { err, id: active.info.id }) + }), + ) + } + + async function launch(state: State, input: StartInput, id = ID.ascending()) { + const sh = Shell.acceptable() + const cwd = path.resolve(state.dir, input.cwd ?? state.dir) + const readyPattern = pattern(input.ready?.pattern) + if (input.ready?.port && (await connected(input.ready.port))) { + throw new Error(`Ready port is already in use: ${input.ready.port}`) + } + const args = Shell.args(sh, input.command, cwd) + const proc = spawn(sh, args, { + cwd, + env: env(), + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: true, + }) + const now = Date.now() + const active: Active = { + ctx: state.ctx, + info: { + id, + sessionID: input.sessionID, + pid: proc.pid, + command: input.command, + cwd, + description: input.description, + ports: [], + status: input.ready ? "starting" : "running", + ready: false, + output: "", + time: { + started: now, + updated: now, + }, + }, + proc, + start: { ...input, cwd }, + pattern: readyPattern, + } + state.processes.set(id, active) + proc.stdout?.on("data", (chunk) => append(active, chunk.toString("utf-8"))) + proc.stderr?.on("data", (chunk) => append(active, chunk.toString("utf-8"))) + proc.once("error", (err) => failed(active, err)) + proc.once("exit", (code, signal) => { + if (state.processes.get(id) !== active) return + exited(active, code, signal) + }) + publish(active) + if (input.ready) await wait(active, input.ready) + return clone(active.info) + } + + const stateLayer = Layer.effect( + StateService, + Effect.gen(function* () { + const ref = yield* InstanceState.make( + Effect.fn("BackgroundProcess.state")(function* (ctx) { + const state: State = { ctx, dir: ctx.directory, processes: new Map() } + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all( + Array.from(state.processes.values()).map((active) => + terminate(state, active, { remove: true, silent: true }), + ), + ) + state.processes.clear() + }), + ) + return state + }), + ) + return StateService.of({ get: () => InstanceState.get(ref) }) + }), + ) + + const runtime = makeRuntime(StateService, stateLayer) + + function state() { + return runtime.runPromise((svc) => svc.get()) + } + + export async function start(input: StartInput) { + return launch(await state(), input) + } + + export async function list(input?: { sessionID?: SessionID }) { + const current = await state() + return Array.from(current.processes.values()) + .map((active) => clone(active.info)) + .filter((info) => !input?.sessionID || info.sessionID === input.sessionID) + .toSorted((a, b) => a.time.started - b.time.started || a.id.localeCompare(b.id)) + } + + export async function get(id: ID) { + const current = await state() + const active = current.processes.get(id) + return active ? clone(active.info) : undefined + } + + export async function logs(id: ID): Promise { + const current = await state() + const active = current.processes.get(id) + if (!active) return + return { id: active.info.id, sessionID: active.info.sessionID, output: active.info.output } + } + + export async function stop(id: ID) { + const current = await state() + const active = current.processes.get(id) + if (!active) return + await terminate(current, active) + return clone(active.info) + } + + export async function restart(id: ID) { + const current = await state() + const active = current.processes.get(id) + if (!active) return + const input = active.start + await terminate(current, active, { remove: true }) + return launch(current, input, id) + } + + export async function stopSession(sessionID: SessionID) { + const current = await state() + const list = Array.from(current.processes.values()).filter((active) => active.info.sessionID === sessionID) + await Promise.all(list.map((active) => terminate(current, active, { remove: true }))) + } +} diff --git a/packages/opencode/src/kilocode/background-process/ports.ts b/packages/opencode/src/kilocode/background-process/ports.ts new file mode 100644 index 00000000000..7afa14e11e1 --- /dev/null +++ b/packages/opencode/src/kilocode/background-process/ports.ts @@ -0,0 +1,138 @@ +import { Process } from "@/util/process" +import fs from "fs/promises" +import path from "path" + +function sorted(items: Iterable) { + return Array.from(items).toSorted((a, b) => a - b) +} + +function parse(addr: string) { + const raw = addr.split(":").at(-1) + if (!raw) return + const port = Number.parseInt(raw, 16) + if (!Number.isFinite(port) || port <= 0) return + return port +} + +async function ppid(pid: number) { + const text = await fs.readFile(`/proc/${pid}/stat`, "utf8") + const match = text.match(/^\d+ \(.+\) \S+ (\d+)/) + if (!match) return + return Number(match[1]) +} + +async function tree(root: number) { + const names = await fs.readdir("/proc") + const rows = await Promise.all( + names + .filter((name) => /^\d+$/.test(name)) + .map(async (name) => { + const pid = Number(name) + const parent = await ppid(pid).catch(() => undefined) + if (!parent) return + return { pid, parent } + }), + ) + const children = new Map() + for (const row of rows) { + if (!row) continue + children.set(row.parent, [...(children.get(row.parent) ?? []), row.pid]) + } + const result = new Set([root]) + const stack = [root] + while (stack.length > 0) { + const pid = stack.pop() + if (!pid) continue + for (const child of children.get(pid) ?? []) { + if (result.has(child)) continue + result.add(child) + stack.push(child) + } + } + return result +} + +async function sockets(pids: Set) { + const all = await Promise.all( + Array.from(pids).map(async (pid) => { + const dir = `/proc/${pid}/fd` + const files = await fs.readdir(dir).catch(() => []) + const links = await Promise.allSettled(files.map((file) => fs.readlink(path.join(dir, file)))) + return links.flatMap((item) => { + if (item.status !== "fulfilled") return [] + const match = item.value.match(/^socket:\[(\d+)\]$/) + return match ? [match[1]] : [] + }) + }), + ) + return new Set(all.flat()) +} + +async function file(name: string, inodes: Set) { + const text = await fs.readFile(name, "utf8").catch(() => "") + return text + .trim() + .split(/\r?\n/) + .slice(1) + .flatMap((line) => { + const parts = line.trim().split(/\s+/) + if (parts[3] !== "0A") return [] + if (!inodes.has(parts[9])) return [] + const port = parse(parts[1]) + return port ? [port] : [] + }) +} + +async function linux(root: number) { + const pids = await tree(root) + const found = await sockets(pids) + if (found.size === 0) return [] + const ports = await Promise.all([file("/proc/net/tcp", found), file("/proc/net/tcp6", found)]) + return sorted(new Set(ports.flat())) +} + +async function ps(root: number) { + const rows = await Process.lines(["ps", "-axo", "pid=,ppid="], { nothrow: true }) + const children = new Map() + for (const row of rows) { + const [pid, parent] = row.trim().split(/\s+/).map(Number) + if (!pid || !parent) continue + children.set(parent, [...(children.get(parent) ?? []), pid]) + } + const result = new Set([root]) + const stack = [root] + while (stack.length > 0) { + const pid = stack.pop() + if (!pid) continue + for (const child of children.get(pid) ?? []) { + if (result.has(child)) continue + result.add(child) + stack.push(child) + } + } + return result +} + +async function lsof(root: number) { + const pids = await ps(root).catch(() => new Set([root])) + const rows = await Process.lines(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", Array.from(pids).join(",")], { + nothrow: true, + }) + return sorted( + new Set( + rows.flatMap((row) => { + const match = row.match(/:(\d+)\s+\(LISTEN\)$/) + return match ? [Number(match[1])] : [] + }), + ), + ) +} + +export async function list(root: number) { + if (process.platform === "linux") { + const ports = await linux(root).catch(() => []) + if (ports.length > 0) return ports + } + if (process.platform === "win32") return [] + return lsof(root).catch(() => []) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index 92873540006..88c6dc81e87 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -22,6 +22,7 @@ import { Link } from "@tui/ui/link" import { isKiloError, showKiloErrorToast } from "@/kilocode/kilo-errors" import { registerKiloCommands } from "@/kilocode/kilo-commands" import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui" +import { DialogProcessList } from "@/kilocode/cli/cmd/tui/component/dialog-process-list" // Re-export so upstream can render the route without importing directly export { KiloClawView } from "@/kilocode/claw/view" @@ -132,6 +133,7 @@ export function init() { const sync = useSync() const sdk = useSDK() const toast = useToast() + const dialog = useDialog() // Inject TUI dependencies for kilo-gateway initializeTUIDependencies({ @@ -154,6 +156,16 @@ export function init() { // Register auto-approve toggle command.register(() => [ + { + title: "Background processes", + description: "List and manage tracked background processes", + value: "background_process.list", + category: "Kilo", + slash: { name: "process", aliases: ["processes"] }, + onSelect: () => { + dialog.replace(() => ) + }, + }, { get title() { return isAllowEverything(sync.data.config.permission) ? "Disable auto-approve mode" : "Enable auto-approve mode" diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx new file mode 100644 index 00000000000..2b6b34a1574 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx @@ -0,0 +1,348 @@ +import type { BackgroundProcessInfo } from "@kilocode/sdk/v2" +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useKeybind } from "@tui/context/keybind" +import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { useTuiConfig } from "@tui/context/tui-config" +import { useToast } from "@tui/ui/toast" +import { getScrollAcceleration } from "@tui/util/scroll" +import { errorMessage } from "@/util/error" +import { Keybind } from "@/util/keybind" +import { Locale } from "@/util/locale" +import { createMemo, createSignal, onMount, Show } from "solid-js" +import stripAnsi from "strip-ansi" + +type Info = BackgroundProcessInfo +type Status = Info["status"] +type Scope = "session" | "all" +type Kind = "stop" | "restart" +type Theme = ReturnType["theme"] + +const stopKey = Keybind.parse("ctrl+o")[0] +const restartKey = Keybind.parse("ctrl+r")[0] +const allKey = Keybind.parse("ctrl+a")[0] + +function terminal(status: Status) { + return status === "exited" || status === "failed" || status === "stopped" +} + +function rank(status: Status) { + if (status === "starting" || status === "stopping") return 0 + if (status === "ready" || status === "running") return 1 + if (status === "failed") return 2 + return 3 +} + +function tone(status: Status, theme: Theme) { + if (status === "ready" || status === "running") return theme.success + if (status === "starting" || status === "stopping") return theme.warning + if (status === "failed") return theme.error + return theme.textMuted +} + +function label(item: Info) { + return item.description?.trim() || item.command +} + +function short(text: string, max = 64) { + return Locale.truncate(text, max) +} + +function ports(item: Info) { + return item.ports.length > 0 ? item.ports.join(", ") : "none" +} + +function useActions() { + const project = useProject() + const sdk = useSDK() + const toast = useToast() + const [busy, setBusy] = createSignal<{ id: string; kind: Kind }>() + + async function run(kind: Kind, item: Info) { + if (busy()) return + if (kind === "stop" && terminal(item.status)) return + + setBusy({ id: item.id, kind }) + const workspace = project.workspace.current() + try { + const result = + kind === "stop" + ? await sdk.client.backgroundProcess.stop({ processID: item.id, workspace }) + : await sdk.client.backgroundProcess.restart({ processID: item.id, workspace }) + + if (result.error) { + toast.show({ + variant: "error", + title: kind === "stop" ? "Failed to stop process" : "Failed to restart process", + message: errorMessage(result.error), + }) + } + } catch (err) { + toast.show({ + variant: "error", + title: kind === "stop" ? "Failed to stop process" : "Failed to restart process", + message: errorMessage(err), + }) + } finally { + setBusy(undefined) + } + } + + return { busy, run } +} + +function StatusMark(props: { status: Status }) { + const { theme } = useTheme() + return * +} + +function Hint(props: { title: string; keys: string; disabled?: boolean; onClick?: () => void }) { + const { theme } = useTheme() + return ( + !props.disabled && props.onClick?.()}> + {props.title}{" "} + {props.keys} + + ) +} + +function session(sync: ReturnType, id: string) { + return sync.data.session.find((item) => item.id === id)?.title +} + +function sort(list: Info[]) { + return list.toSorted( + (a, b) => rank(a.status) - rank(b.status) || b.time.updated - a.time.updated || a.id.localeCompare(b.id), + ) +} + +function all(sync: ReturnType) { + return Object.values(sync.data.background_process).flat() +} + +export function DialogProcessList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const keybind = useKeybind() + const actions = useActions() + const sid = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const [scope, setScope] = createSignal(sid() ? "session" : "all") + const mode = createMemo(() => (scope() === "session" && sid() ? "session" : "all")) + + const list = createMemo(() => { + const current = sid() + const items = mode() === "session" && current ? (sync.data.background_process[current] ?? []) : all(sync) + return sort(items) + }) + + const options = createMemo[]>(() => { + const busy = actions.busy() + return list().map((item) => { + const note = mode() === "all" ? session(sync, item.sessionID) : undefined + const footer = busy?.id === item.id ? `${busy.kind === "stop" ? "stopping" : "restarting"}...` : item.pid?.toString() + const title = `${note ? `(${note}) ` : ""}${label(item)} - ${item.command}` + + return { + title: short(title, 92), + value: item.id, + footer, + gutter: () => , + } + }) + }) + + onMount(() => { + dialog.setSize("large") + }) + + return ( + { + dialog.replace(() => ( + dialog.replace(() => )} /> + )) + }} + keybind={[ + { + keybind: stopKey, + title: "stop", + onTrigger: (option) => { + const item = list().find((proc) => proc.id === option.value) + if (item) void actions.run("stop", item) + }, + }, + { + keybind: restartKey, + title: "restart", + onTrigger: (option) => { + const item = list().find((proc) => proc.id === option.value) + if (item) void actions.run("restart", item) + }, + }, + { + keybind: allKey, + title: mode() === "session" ? "all" : "current", + disabled: !sid(), + side: "right", + onTrigger: () => { + setScope((value) => (value === "session" ? "all" : "session")) + }, + }, + ]} + /> + ) +} + +function DialogProcessDetail(props: { id: string; back: () => void }) { + const dialog = useDialog() + const sync = useSync() + const keybind = useKeybind() + const actions = useActions() + const dimensions = useTerminalDimensions() + const config = useTuiConfig() + const { theme } = useTheme() + const item = createMemo(() => all(sync).find((proc) => proc.id === props.id)) + const output = createMemo(() => stripAnsi(item()?.output ?? "")) + const height = createMemo(() => Math.max(4, Math.floor(dimensions().height / 2) - 14)) + const busy = createMemo(() => actions.busy()?.id === props.id) + const stopped = createMemo(() => { + const proc = item() + return proc ? terminal(proc.status) : true + }) + const scroll = createMemo(() => getScrollAcceleration(config)) + let box: ScrollBoxRenderable | undefined + + onMount(() => { + dialog.setSize("large") + }) + + useKeyboard((evt) => { + if (evt.defaultPrevented) return + const proc = item() + if (keybind.match("backspace", evt)) { + evt.preventDefault() + evt.stopPropagation() + props.back() + return + } + if (keybind.match("ctrl+o", evt)) { + evt.preventDefault() + evt.stopPropagation() + if (proc) void actions.run("stop", proc) + return + } + if (keybind.match("ctrl+r", evt)) { + evt.preventDefault() + evt.stopPropagation() + if (proc) void actions.run("restart", proc) + return + } + if (evt.name === "pageup") { + evt.preventDefault() + evt.stopPropagation() + box?.scrollBy(-height()) + return + } + if (evt.name === "pagedown") { + evt.preventDefault() + evt.stopPropagation() + box?.scrollBy(height()) + } + }) + + return ( + + + + {item() ? short(label(item()!), 92) : "Background Process"} + + dialog.clear()}> + esc + + + Process is no longer tracked.}> + {(proc) => ( + <> + + + Name: {label(proc())} + + + Status:{" "} + {proc().status} + + PID: {proc().pid ?? "none"} + Ports: {ports(proc())} + + Exit: {proc().exitCode} + + + {(signal) => Signal: {signal()}} + + Started: {Locale.datetime(proc().time.started)} + Updated: {Locale.datetime(proc().time.updated)} + + {(ended) => Ended: {Locale.datetime(ended())}} + + + CWD: {proc().cwd} + + + Command: {proc().command} + + + + + Output Buffer + + (box = ref)} + height={height()} + scrollAcceleration={scroll()} + stickyScroll={true} + stickyStart="bottom" + verticalScrollbarOptions={{ visible: true }} + > + No output yet}> + {(text) => ( + + {text()} + + )} + + + + + + + void actions.run("stop", proc())} + /> + void actions.run("restart", proc())} + /> + + pageup/pagedown scroll + + + )} + + + ) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/kilocode/cli/cmd/tui/util/terminal.ts index c7c0e260a8a..2a5c466e081 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/util/terminal.ts @@ -1,21 +1,55 @@ -// kilocode_change - new file +import fs from "node:fs" + /** - * Write escape sequences to disable all mouse tracking modes and reset terminal state. + * Write escape sequences to disable terminal input modes and reset terminal state. * This is a safety net to ensure the terminal is clean after exit, even if the renderer's * cleanup didn't flush properly (e.g. on Windows). */ -export function resetTerminalState() { - const sequences = [ +function truthy(key: string) { + const value = process.env[key]?.toLowerCase() + return value === "true" || value === "1" +} + +export function kitty() { + if (truthy("KILO_DISABLE_KITTY_KEYBOARD")) return false + if (truthy("KILO_ENABLE_KITTY_KEYBOARD")) return true + + const term = process.env.TERM_PROGRAM?.toLowerCase() + const system = process.env.MSYSTEM?.toLowerCase() + + if (term === "mintty") return false + if (system) return false + + return true +} + +export function sequences() { + return [ + "\x1b[?9l", // disable X10 mouse tracking "\x1b[?1000l", // disable normal mouse tracking + "\x1b[?1001l", // disable highlight mouse tracking "\x1b[?1002l", // disable button-event mouse tracking "\x1b[?1003l", // disable any-event mouse tracking (all movement) + "\x1b[?1005l", // disable UTF-8 extended mouse mode "\x1b[?1006l", // disable SGR extended mouse mode + "\x1b[?1007l", // disable alternate scroll mode "\x1b[?1015l", // disable RXVT mouse mode - "\x1b[", // disable application keypad mode + "\x1b[?66l", // disable numeric keypad application mode + "\x1b[>4;0m", // reset xterm modifyOtherKeys + ...(kitty() ? ["\x1b[ props.api.theme.current + const list = createMemo(() => props.api.state.session.processes(props.session_id)) + + return ( + 0}> + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + Background Processes + + + + + {(item) => ( + + + ● {short(label(item))} + + {short(item.command)} + + {(pid) => PID: {pid()}} + + 0}> + PORTS: {item.ports.join(", ")} + + + )} + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 250, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts new file mode 100644 index 00000000000..28e2be8a52e --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts @@ -0,0 +1,108 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { SessionID } from "@/session/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { described } from "@/server/routes/instance/httpapi/groups/metadata" + +const root = "/background-process" + +export const BackgroundProcessPaths = { + list: root, + get: `${root}/:processID`, + logs: `${root}/:processID/logs`, + stop: `${root}/:processID/stop`, + restart: `${root}/:processID/restart`, + stopSession: `${root}/session/:sessionID/stop`, +} as const + +export const Params = Schema.Struct({ processID: BackgroundProcess.ID }) +export const SessionParams = Schema.Struct({ sessionID: SessionID }) + +export const BackgroundProcessApi = HttpApi.make("background-process") + .add( + HttpApiGroup.make("background-process") + .add( + HttpApiEndpoint.get("list", BackgroundProcessPaths.list, { + success: described(Schema.Array(BackgroundProcess.Info), "List of background processes"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.list", + summary: "List background processes", + description: "List tracked background processes for the current instance.", + }), + ), + HttpApiEndpoint.get("get", BackgroundProcessPaths.get, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Info, "Background process info"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.get", + summary: "Get background process", + description: "Get status and retained output for one background process.", + }), + ), + HttpApiEndpoint.get("logs", BackgroundProcessPaths.logs, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Logs, "Background process logs"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.logs", + summary: "Get background process logs", + description: "Get the retained output tail for one background process.", + }), + ), + HttpApiEndpoint.post("stop", BackgroundProcessPaths.stop, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Info, "Stopped background process"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.stop", + summary: "Stop background process", + description: "Terminate a background process and its child process tree.", + }), + ), + HttpApiEndpoint.post("restart", BackgroundProcessPaths.restart, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Info, "Restarted background process"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.restart", + summary: "Restart background process", + description: "Stop and restart a background process with its original command.", + }), + ), + HttpApiEndpoint.post("stopSession", BackgroundProcessPaths.stopSession, { + params: { sessionID: SessionID }, + success: described(Schema.Boolean, "Stopped session background processes"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.stopSession", + summary: "Stop session background processes", + description: "Terminate and forget all background processes associated with one session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "background-process", + description: "Kilo background process routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "kilo HttpApi", + version: "0.0.1", + description: "Kilo HttpApi surface.", + }), + ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts new file mode 100644 index 00000000000..5c4ab90e2f1 --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts @@ -0,0 +1,62 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" +import { SessionID } from "@/session/schema" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" + +const missing = () => new HttpApiError.NotFound({}) + +export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, "background-process", (handlers) => + Effect.gen(function* () { + const list = Effect.fn("BackgroundProcessHttpApi.list")(function* () { + return yield* Effect.promise(() => BackgroundProcess.list()) + }) + + const get = Effect.fn("BackgroundProcessHttpApi.get")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.get(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const logs = Effect.fn("BackgroundProcessHttpApi.logs")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.logs(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const stop = Effect.fn("BackgroundProcessHttpApi.stop")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.stop(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const restart = Effect.fn("BackgroundProcessHttpApi.restart")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.restart(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const stopSession = Effect.fn("BackgroundProcessHttpApi.stopSession")(function* (ctx: { + params: { sessionID: SessionID } + }) { + yield* Effect.promise(() => BackgroundProcess.stopSession(ctx.params.sessionID)) + return true + }) + + return handlers + .handle("list", list) + .handle("get", get) + .handle("logs", logs) + .handle("stop", stop) + .handle("restart", restart) + .handle("stopSession", stopSession) + }), +) diff --git a/packages/opencode/src/kilocode/server/httpapi/instance.ts b/packages/opencode/src/kilocode/server/httpapi/instance.ts index 980b8f92ce1..2e823ed1e4e 100644 --- a/packages/opencode/src/kilocode/server/httpapi/instance.ts +++ b/packages/opencode/src/kilocode/server/httpapi/instance.ts @@ -1,5 +1,6 @@ import type { Context } from "effect" import type { Hono } from "hono" +import { BackgroundProcessPaths } from "./groups/background-process" import { IndexingPaths } from "./groups/indexing" import { KiloGatewayPaths } from "./groups/kilo-gateway" import { KilocodePaths } from "./groups/kilocode" @@ -12,6 +13,12 @@ import { TelemetryPaths } from "./groups/telemetry" type Handler = (request: Request, context: Context.Context) => Promise export function register(app: Hono, handler: Handler, context: Context.Context) { + app.get(BackgroundProcessPaths.list, (c) => handler(c.req.raw, context)) + app.get(BackgroundProcessPaths.get, (c) => handler(c.req.raw, context)) + app.get(BackgroundProcessPaths.logs, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.stop, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.restart, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.stopSession, (c) => handler(c.req.raw, context)) app.post("/permission/allow-everything", (c) => handler(c.req.raw, context)) app.post("/enhance-prompt", (c) => handler(c.req.raw, context)) app.post("/commit-message", (c) => handler(c.req.raw, context)) diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index ca9b07c47f2..f82f17fe986 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -1,5 +1,6 @@ import { Layer } from "effect" +import { backgroundProcessHandlers } from "./handlers/background-process" import { commitMessageHandlers } from "./handlers/commit-message" import { enhancePromptHandlers } from "./handlers/enhance-prompt" import { indexingHandlers } from "./handlers/indexing" @@ -12,6 +13,7 @@ import { suggestionHandlers } from "./handlers/suggestion" import { telemetryHandlers } from "./handlers/telemetry" export const provide = Layer.provide([ + backgroundProcessHandlers, commitMessageHandlers, enhancePromptHandlers, indexingHandlers, diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index d309e057723..20a54636151 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -14,6 +14,7 @@ import { RemoteRoutes } from "../../server/routes/instance/remote" import { NetworkRoutes } from "../../server/routes/instance/network" import { SuggestionRoutes } from "../suggestion/routes" import { IndexingRoutes } from "./routes/indexing" +import { BackgroundProcessRoutes } from "./routes/background-process" import { createKiloRoutes } from "@kilocode/kilo-gateway" import { Auth } from "../../auth" import { errors } from "../../server/error" @@ -28,6 +29,7 @@ import { Bus } from "@/bus" export function register(app: Hono): Hono { return app + .route("/background-process", BackgroundProcessRoutes()) .route("/permission", PermissionKilocodeRoutes()) .route("/network", NetworkRoutes()) .route("/indexing", IndexingRoutes()) // kilocode_change diff --git a/packages/opencode/src/kilocode/server/routes/background-process.ts b/packages/opencode/src/kilocode/server/routes/background-process.ts new file mode 100644 index 00000000000..081bc7f1cae --- /dev/null +++ b/packages/opencode/src/kilocode/server/routes/background-process.ts @@ -0,0 +1,130 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { errors } from "@/server/error" +import { NotFoundError } from "@/storage/storage" +import { SessionID } from "@/session/schema" +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { lazy } from "@/util/lazy" +import z from "zod" + +export const BackgroundProcessRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List background processes", + description: "List tracked background processes for the current instance.", + operationId: "backgroundProcess.list", + responses: { + 200: { + description: "List of background processes", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod.array()) } }, + }, + }, + }), + async (c) => c.json(await BackgroundProcess.list()), + ) + .get( + "/:processID", + describeRoute({ + summary: "Get background process", + description: "Get status and retained output for one background process.", + operationId: "backgroundProcess.get", + responses: { + 200: { + description: "Background process info", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.get(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .get( + "/:processID/logs", + describeRoute({ + summary: "Get background process logs", + description: "Get the retained output tail for one background process.", + operationId: "backgroundProcess.logs", + responses: { + 200: { + description: "Background process logs", + content: { "application/json": { schema: resolver(BackgroundProcess.Logs.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.logs(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .post( + "/:processID/stop", + describeRoute({ + summary: "Stop background process", + description: "Terminate a background process and its child process tree.", + operationId: "backgroundProcess.stop", + responses: { + 200: { + description: "Stopped background process", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.stop(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .post( + "/:processID/restart", + describeRoute({ + summary: "Restart background process", + description: "Stop and restart a background process with its original command.", + operationId: "backgroundProcess.restart", + responses: { + 200: { + description: "Restarted background process", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.restart(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .post( + "/session/:sessionID/stop", + describeRoute({ + summary: "Stop session background processes", + description: "Terminate and forget all background processes associated with one session.", + operationId: "backgroundProcess.stopSession", + responses: { + 200: { + description: "Stopped session background processes", + content: { "application/json": { schema: resolver(z.boolean()) } }, + }, + }, + }), + validator("param", z.object({ sessionID: SessionID.zod })), + async (c) => { + await BackgroundProcess.stopSession(c.req.valid("param").sessionID) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index 098c1acbfa3..6d1d379b8cb 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -4,6 +4,7 @@ import { SessionNetwork } from "@/session/network" import type { SessionID } from "@/session/schema" import type { SessionStatus } from "@/session/status" import { MessageV2 } from "@/session/message-v2" +import { isRecord } from "@/util/record" import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" @@ -12,6 +13,7 @@ export type ReviewTelemetry = { mode: "review" feature: "code_reviews" command: "review" | "local-review" | "local-review-uncommitted" + tool?: "suggest" } export namespace KiloSessionProcessor { @@ -28,6 +30,13 @@ export namespace KiloSessionProcessor { } } + function command(prompt: string | undefined) { + if (!prompt?.startsWith("/")) return + const name = prompt.slice(1).split(/\s/, 1)[0] + if (!name) return + return name + } + /** * Tag the text parts of a prompt with review telemetry metadata so that * downstream LLM completions in the same turn (including child sessions @@ -59,6 +68,25 @@ export namespace KiloSessionProcessor { } } + export function suggestionReviewTelemetry(metadata: unknown): ReviewTelemetry | undefined { + if (!isRecord(metadata)) return + if (!isRecord(metadata.accepted)) return + const prompt = typeof metadata.accepted.prompt === "string" ? metadata.accepted.prompt : undefined + const tel = reviewTelemetry(command(prompt)) + if (!tel) return + return { ...tel, tool: "suggest" } + } + + export function extractSuggestionReviewTelemetry(parts: MessageV2.Part[]): ReviewTelemetry | undefined { + for (const part of parts) { + if (part.type !== "tool") continue + if (part.tool !== "suggest") continue + if (part.state.status !== "completed") continue + const tel = suggestionReviewTelemetry(part.state.metadata) + if (tel) return tel + } + } + /** * Track LLM completion telemetry for a finished step. * Only fires if at least one token bucket is non-zero. diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 1d319400110..833efa03bde 100644 --- a/packages/opencode/src/kilocode/suggestion/index.ts +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -4,6 +4,7 @@ import { Identifier } from "../../id/id" import { SessionID } from "../../session/schema" import { ZodOverride } from "../../util/effect-zod" import * as Log from "@opencode-ai/core/util/log" +import { Telemetry, type ReviewCommand } from "@kilocode/kilo-telemetry" import z from "zod" import { Schema } from "effect" import { KiloSessionPromptQueue } from "../session/prompt-queue" @@ -11,6 +12,12 @@ import { KiloSessionPromptQueue } from "../session/prompt-queue" export namespace Suggestion { const log = Log.create({ service: "suggestion" }) + function command(prompt: string): ReviewCommand | undefined { + if (!prompt.startsWith("/")) return + const name = prompt.slice(1).split(/\s/, 1)[0] + if (name === "review" || name === "local-review" || name === "local-review-uncommitted") return name + } + export const Action = z .object({ label: z.string().describe("Button or option label (1-5 words)"), @@ -152,6 +159,18 @@ export namespace Suggestion { resolve, reject, } + info.actions.forEach((action, index) => { + const cmd = command(action.prompt) + if (!cmd) return + Telemetry.trackSuggestionShown({ + sessionId: info.sessionID, + requestId: info.id, + index, + tool: "suggest", + command: cmd, + actionCount: info.actions.length, + }) + }) Bus.publish(Event.Shown, { ...info, sessionID: SessionID.make(info.sessionID) }) }) } @@ -176,6 +195,18 @@ export namespace Suggestion { log.info("accepted", { requestID: input.requestID, index: input.index, label: action.label }) + const cmd = command(action.prompt) + if (cmd) { + Telemetry.trackSuggestionAccepted({ + sessionId: existing.info.sessionID, + requestId: existing.info.id, + index: input.index, + tool: "suggest", + command: cmd, + actionCount: existing.info.actions.length, + }) + } + Bus.publish(Event.Accepted, { sessionID: SessionID.make(existing.info.sessionID), requestID: existing.info.id, diff --git a/packages/opencode/src/kilocode/tool/background-process.ts b/packages/opencode/src/kilocode/tool/background-process.ts new file mode 100644 index 00000000000..05ca5154757 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/background-process.ts @@ -0,0 +1,175 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { Tool } from "@/tool/tool" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { Effect, Schema } from "effect" +import DESCRIPTION from "./background-process.txt" +import path from "path" + +const Action = Schema.Literals(["start", "list", "status", "logs", "stop", "restart"]) +type Action = Schema.Schema.Type + +export const Params = Schema.Struct({ + action: Action.annotate({ description: "Operation to perform" }), + command: Schema.optional(Schema.String).annotate({ + description: "Required for start. Command to run as a tracked background process.", + }), + id: Schema.optional(BackgroundProcess.ID.annotate({ description: "Required for status, logs, stop, and restart" })), + workdir: Schema.optional(Schema.String).annotate({ + description: "Working directory for start. Defaults to the project directory.", + }), + description: Schema.optional(Schema.String).annotate({ description: "Short label shown in the sidebar" }), + ready: Schema.optional(BackgroundProcess.Ready).annotate({ description: "Optional readiness probe for start" }), +}).check( + Schema.makeFilter((params: { action: Action; command?: string; id?: BackgroundProcess.ID }) => { + if (params.action === "start") { + if (params.command?.trim()) return undefined + return "command is required when action is start" + } + if (params.action === "list") return undefined + if (params.id) return undefined + return "id is required when action is status, logs, stop, or restart" + }), +) +export type Params = Schema.Schema.Type + +type Meta = { + processID?: BackgroundProcess.ID + status?: BackgroundProcess.Status + count?: number +} + +function title(info: BackgroundProcess.Info) { + return info.description ?? info.command +} + +function last(text: string) { + const lines = text.trim().split(/\r?\n/).filter(Boolean) + return lines.at(-1) ?? "" +} + +function format(info: BackgroundProcess.Info) { + return [ + `id: ${info.id}`, + `status: ${info.status}`, + info.pid ? `pid: ${info.pid}` : undefined, + `cwd: ${info.cwd}`, + `command: ${info.command}`, + last(info.output) ? `last_output: ${last(info.output)}` : undefined, + ] + .filter(Boolean) + .join("\n") +} + +function invalid(action: Action, message: string) { + return { + title: "Invalid background process input", + output: `${message} for action: ${action}`, + metadata: {}, + } +} + +function missing(id: BackgroundProcess.ID) { + return { + title: "Background process not found", + output: `Background process not found: ${id}`, + metadata: { processID: id }, + } +} + +function pattern(ready?: BackgroundProcess.Ready) { + if (!ready?.pattern) return + try { + new RegExp(ready.pattern) + } catch (err) { + return `Invalid ready pattern: ${err instanceof Error ? err.message : String(err)}` + } +} + +export const BackgroundProcessTool = Tool.define( + "background_process", + Effect.succeed({ + description: DESCRIPTION, + parameters: Params, + execute: (params, ctx) => + Effect.gen(function* () { + if (params.action === "list") { + const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID: ctx.sessionID })) + return { + title: "Background processes", + output: list.length + ? list.map(format).join("\n\n") + : "No background processes are running for this session.", + metadata: { count: list.length }, + } + } + + if (params.action !== "start") { + const id = params.id + if (!id) return invalid(params.action, "Missing id") + const found = yield* Effect.promise(() => BackgroundProcess.get(id)) + if (!found || found.sessionID !== ctx.sessionID) return missing(id) + if (params.action === "logs") { + const logs = yield* Effect.promise(() => BackgroundProcess.logs(id)) + if (!logs) return missing(id) + return { + title: `Logs: ${title(found)}`, + output: logs.output || "(no output)", + metadata: { processID: found.id, status: found.status }, + } + } + const info = + params.action === "stop" + ? yield* Effect.promise(() => BackgroundProcess.stop(id)) + : params.action === "restart" + ? yield* Effect.promise(() => BackgroundProcess.restart(id)) + : found + if (!info) return missing(id) + return { + title: `${params.action}: ${title(info)}`, + output: format(info), + metadata: { processID: info.id, status: info.status }, + } + } + + const command = params.command?.trim() + if (!command) return invalid(params.action, "Missing command") + const err = pattern(params.ready) + if (err) return invalid(params.action, err) + const inst = yield* InstanceState.context + const cwd = path.resolve(inst.directory, params.workdir ?? inst.directory) + if (!containsPath(cwd, inst)) { + const pattern = + process.platform === "win32" ? AppFileSystem.normalizePathPattern(path.join(cwd, "*")) : path.join(cwd, "*") + yield* ctx.ask({ + permission: "external_directory", + patterns: [pattern], + always: [pattern], + metadata: { command, access: "unknown" }, + }) + } + yield* ctx.ask({ + permission: "bash", + patterns: [command], + always: [command.split(/\s+/, 1)[0] + " *"], + metadata: { command, description: params.description, action: "start", backgroundProcess: true }, + }) + + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID: ctx.sessionID, + command, + cwd, + description: params.description, + ready: params.ready, + }), + ) + return { + title: `Started: ${title(info)}`, + output: format(info), + metadata: { processID: info.id, status: info.status }, + } + }), + }), +) diff --git a/packages/opencode/src/kilocode/tool/background-process.txt b/packages/opencode/src/kilocode/tool/background-process.txt new file mode 100644 index 00000000000..e88e2aa90b3 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/background-process.txt @@ -0,0 +1,20 @@ +Run and manage long-running background processes for the current session. + +Use this tool for development servers, file watchers, local services, and commands that are expected to keep running, such as `npm run dev`, `next dev`, `vite`, `bun --watch`, or test watchers. + +Do not use the shell tool with `&`, `nohup`, `disown`, `setsid`, `Start-Process`, or similar backgrounding patterns. Processes started with this tool are tracked, shown in the CLI sidebar, and automatically stopped when the user switches sessions or exits the TUI. + +Actions: +- `start`: start a new background process. Include `command`, optional `workdir`, optional `description`, and optional `ready` detection. +- `list`: list background processes for this session. +- `status`: inspect one process by `id`. +- `logs`: return the retained tail output for one process. +- `stop`: terminate one process and its child process tree. +- `restart`: stop and restart one process with its original command. + +Only include `id` for `status`, `logs`, `stop`, and `restart`. Do not invent or pass an `id` when starting a process. + +Readiness: +- Use `ready.pattern` when the process prints a recognizable line like `ready`, `Local:`, or `started server`. +- Use `ready.port` when a local server should accept TCP connections on a known port. +- If readiness is not known, omit `ready`; the process is returned as running immediately. diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 503214db543..497dabcc90f 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -2,6 +2,7 @@ import { CodebaseSearchTool } from "../../tool/warpgrep" import { RecallTool } from "../../tool/recall" import { AgentManagerTool } from "./agent-manager" +import { BackgroundProcessTool } from "./background-process" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" @@ -23,18 +24,23 @@ export namespace KiloToolRegistry { const codebase = yield* CodebaseSearchTool const recall = yield* RecallTool const manager = yield* AgentManagerTool - return { codebase, recall, manager } + const process = yield* BackgroundProcessTool + return { codebase, recall, manager, process } }) } /** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect — * it has no Service deps beyond what Tool.init itself needs. */ - export function build(tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info }, deps: Deps) { + export function build( + tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info }, + deps: Deps, + ) { return Effect.gen(function* () { const base = yield* Effect.all({ codebase: Tool.init(tools.codebase), recall: Tool.init(tools.recall), manager: Tool.init(tools.manager), + process: Tool.init(tools.process), }) const semantic = yield* semanticTool(deps) return { ...base, semantic } @@ -76,13 +82,14 @@ export namespace KiloToolRegistry { /** Kilo-specific tools to append to the builtin list */ export function extra( - tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def }, + tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def }, cfg: { experimental?: { codebase_search?: boolean; agent_manager_tool?: boolean } }, ): Tool.Def[] { return [ ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), ...(tools.semantic ? [tools.semantic] : []), tools.recall, + ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), // The extension is the only client that can consume the Agent Manager start event. ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.agent_manager_tool === true ? [tools.manager] : []), ] diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 67981af3107..65892fa7111 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -112,7 +112,7 @@ function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): id_token_add_organizations: "true", codex_cli_simplified_flow: "true", state, - originator: "opencode", + originator: "kilo", // kilocode_change }) return `${ISSUER}/oauth/authorize?${params.toString()}` } diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index a262b8f4d57..9d7f14ad5bb 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" +import { zod, zodObject } from "@/util/effect-zod" import { makeRuntime } from "@/effect/run-service" // kilocode_change import { NonNegativeInt, withStatics } from "@/util/schema" @@ -86,7 +86,9 @@ const fileFromPatchChunk = (chunk: string) => { } const splitGitPatch = (patch: Git.Patch) => { - const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) if (!patch.truncated) return chunks return chunks.slice(0, -1) @@ -238,11 +240,39 @@ export const FileDiff = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = Schema.Schema.Type +export const FileStatus = Schema.Struct({ + file: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}) + .annotate({ identifier: "VcsFileStatus" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FileStatus = Schema.Schema.Type + +export const ApplyInput = Schema.Struct({ + patch: Schema.String, +}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) +export type ApplyInput = Schema.Schema.Type + +export const ApplyResult = Schema.Struct({ + applied: Schema.Boolean, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ApplyResult = Schema.Schema.Type + +export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), +}) {} + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect readonly defaultBranch: () => Effect.Effect + readonly status: () => Effect.Effect readonly diff: (mode: Mode) => Effect.Effect + readonly diffRaw: () => Effect.Effect + readonly apply: (input: ApplyInput) => Effect.Effect } interface State { @@ -303,6 +333,31 @@ export const layer: Layer.Layer = Lay defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { return yield* InstanceState.use(state, (x) => x.root?.name) }), + status: Effect.fn("Vcs.status")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] + const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined + const [list, stats] = yield* Effect.all( + [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])], + { concurrency: 2 }, + ) + const map = nums(stats) + return yield* Effect.forEach( + list.toSorted((a, b) => a.file.localeCompare(b.file)), + (item) => + Effect.gen(function* () { + const stat = + map.get(item.file) ?? + (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined) + return { + file: item.file, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + } satisfies FileStatus + }), + ) + }), diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { const value = yield* InstanceState.get(state) const ctx = yield* InstanceState.context @@ -317,6 +372,36 @@ export const layer: Layer.Layer = Lay if (!ref) return [] return yield* diffAgainstRef(git, ctx.directory, ref) }), + diffRaw: Effect.fn("Vcs.diffRaw")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return "" + const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { + concurrency: 2, + }) + const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : "" + const untracked = yield* Effect.forEach( + status.filter((item) => item.code === "??"), + (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)), + ) + return [tracked, ...untracked].filter(Boolean).join("\n") + }), + apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new PatchApplyError({ + message: "Patch can't be applied because the project is not git-based", + reason: "non-git", + }) + } + const applied = yield* git.applyPatch(ctx.directory, input.patch) + if (applied.exitCode !== 0) { + return yield* new PatchApplyError({ + message: "Patch can't be applied", + reason: "not-clean", + }) + } + return { applied: true } + }), }) }), ) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 2c49233816a..b5fda9e40f8 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -156,6 +156,7 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined isRetryable: false, responseBody, } + case "server_is_overloaded": case "server_error": return { type: "api_error", diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 35ea19b5f69..ab7a81ec818 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,4 +1,4 @@ -import type { ModelMessage } from "ai" +import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" @@ -21,6 +21,10 @@ function mimeToModality(mime: string): Modality | undefined { export const OUTPUT_TOKEN_MAX = Flag.KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 +export function sanitizeSurrogates(content: string) { + return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?, ): ModelMessage[] { + const sanitizeToolResultOutput = (content: ToolResultPart) => { + if (content.output.type === "text" || content.output.type === "error-text") { + content.output.value = sanitizeSurrogates(content.output.value) + } + if (content.output.type === "content") { + content.output.value = content.output.value.map((item) => { + if (item.type === "text") { + item.text = sanitizeSurrogates(item.text) + } + return item + }) + } + return content + } + + msgs = msgs.map((msg) => { + switch (msg.role) { + case "tool": + if (!Array.isArray(msg.content)) return msg + msg.content = msg.content.map((content) => { + if (content.type === "tool-result") { + return sanitizeToolResultOutput(content) + } + return content + }) + return msg + + case "system": + msg.content = sanitizeSurrogates(msg.content) + return msg + + case "user": + if (typeof msg.content === "string") { + msg.content = sanitizeSurrogates(msg.content) + } else { + msg.content = msg.content.map((content) => { + if (content.type === "text") { + content.text = sanitizeSurrogates(content.text) + } + return content + }) + } + return msg + + case "assistant": + if (typeof msg.content === "string") { + msg.content = sanitizeSurrogates(msg.content) + } else { + msg.content = msg.content.map((content) => { + if (content.type === "text" || content.type === "reasoning") { + content.text = sanitizeSurrogates(content.text) + } + if (content.type === "tool-result") { + return sanitizeToolResultOutput(content) + } + return content + }) + } + return msg + } + }) + // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -437,6 +511,36 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +// OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). +// Models released before it 400 on `reasoning_effort: "none"`, so we only expose +// it as a variant for models new enough to accept it. +const OPENAI_NONE_EFFORT_RELEASE_DATE = "2025-11-13" + +// OpenAI rolled out the `xhigh` reasoning_effort tier on this date. Same reasoning. +const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" + +// Matches members of the gpt-5 family across the id formats we encounter: +// "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex". +// Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". +const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ + +// Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream +// routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models +// with no tunable effort knob (gpt-5-pro). Effort order: weakest to strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { + const id = apiId.toLowerCase() + if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null + if (id.includes("codex")) { + if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + return [...WIDELY_SUPPORTED_EFFORTS] + } + const efforts = [...WIDELY_SUPPORTED_EFFORTS] + if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") + if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") + if (releaseDate >= OPENAI_XHIGH_EFFORT_RELEASE_DATE) efforts.push("xhigh") + return efforts +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "xhigh", "max"] @@ -514,6 +618,21 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + case "ai-gateway-provider": { + // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible + // /v1/compat endpoint, so the body is always OAI-shaped. The gateway + // translates `reasoning_effort` to the upstream provider's native control + // (e.g. Anthropic thinking budgets) when needed. Variants therefore stay + // OAI-style for all upstreams, with an extended effort set for OpenAI + // models that support it. + if (model.api.id.startsWith("openai/")) { + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + if (!efforts) return {} + return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + case "@ai-sdk/gateway": if (model.id.includes("anthropic")) { if (adaptiveEfforts) { @@ -638,28 +757,12 @@ export function variants(model: Provider.Model): Record { - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return WIDELY_SUPPORTED_EFFORTS - } - const arr = [...WIDELY_SUPPORTED_EFFORTS] - if (id.includes("gpt-5-") || id === "gpt-5") { - arr.unshift("minimal") - } - if (model.release_date >= "2025-11-13") { - arr.unshift("none") - } - if (model.release_date >= "2025-12-04") { - arr.push("xhigh") - } - return arr - }) + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + if (!efforts) return {} return Object.fromEntries( - openaiEfforts.map((effort) => [ + efforts.map((effort) => [ effort, { reasoningEffort: effort, @@ -668,6 +771,7 @@ export function variants(model: Provider.Model): Record mistralId.includes(id))) return {} return { diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts index 2f6b2787f50..55ced40f772 100644 --- a/packages/opencode/src/server/adapter.node.ts +++ b/packages/opencode/src/server/adapter.node.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events" import { createAdaptorServer, type ServerType } from "@hono/node-server" import { createNodeWebSocket } from "@hono/node-ws" import type { Hono } from "hono" @@ -7,6 +8,7 @@ async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) = const start = (port: number) => new Promise((resolve, reject) => { const server = createAdaptorServer({ fetch: app.fetch }) + const events = server as EventEmitter inject?.(server) const fail = (err: Error) => { cleanup() @@ -17,11 +19,11 @@ async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) = resolve(server) } const cleanup = () => { - server.off("error", fail) - server.off("listening", ready) + events.off("error", fail) + events.off("listening", ready) } - server.once("error", fail) - server.once("listening", ready) + events.once("error", fail) + events.once("listening", ready) server.listen(port, opts.hostname) }) diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 21a7810ce1e..0c1bf252eda 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -8,12 +8,9 @@ import { AppRuntime } from "@/effect/app-runtime" import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" +import { Vcs } from "@/project/vcs" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { errorData } from "@/util/error" - -const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() @@ -151,60 +148,64 @@ export const WorkspaceRoutes = lazy(() => }, ) .post( - "/:id/session-restore", + "/warp", describeRoute({ - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - operationId: "experimental.workspace.sessionRestore", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", + operationId: "experimental.workspace.warp", responses: { - 200: { - description: "Session replay started", - content: { - "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), - }, - }, + 204: { + description: "Session warped", }, ...errors(400), }, }), - validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), - validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), + validator( + "json", + z.object({ + id: zodObject(Workspace.Info).shape.id.nullable(), + sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + copyChanges: z.boolean().optional(), + }), + ), async (c) => { - const { id } = c.req.valid("param") - const body = c.req.valid("json") as Omit - log.info("session restore route requested", { - workspaceID: id, - sessionID: body.sessionID, - directory: Instance.directory, - }) - try { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.sessionRestore({ - workspaceID: id, - ...body, - }), - ), - ) - log.info("session restore route complete", { - workspaceID: id, - sessionID: body.sessionID, - total: result.total, - }) - return c.json(result) - } catch (err) { - log.error("session restore route failed", { - workspaceID: id, - sessionID: body.sessionID, - error: errorData(err), - }) - throw err - } + const body = c.req.valid("json") + return AppRuntime.runPromise( + Workspace.Service.use((workspace) => + workspace.sessionWarp({ + workspaceID: body.id, + sessionID: body.sessionID, + copyChanges: body.copyChanges, + }), + ).pipe( + Effect.match({ + onFailure: (error) => { + if (error instanceof Vcs.PatchApplyError) { + return c.json( + { + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }, + 400, + ) + } + return c.json( + { + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }, + 400, + ) + }, + onSuccess: () => c.body(null, 204), + }), + ), + ) }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md index 757d7aed0c3..a6ccf794ddc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md +++ b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md @@ -32,4 +32,6 @@ Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally re Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction. +Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary. + When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled. diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 7b7d6b640c2..1121970da6f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -22,6 +22,7 @@ import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" // kilocode_change start - Kilo HttpApi groups import { CommitMessageApi } from "@/kilocode/server/httpapi/groups/commit-message" +import { BackgroundProcessApi } from "@/kilocode/server/httpapi/groups/background-process" import { EnhancePromptApi } from "@/kilocode/server/httpapi/groups/enhance-prompt" import { IndexingApi } from "@/kilocode/server/httpapi/groups/indexing" import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway" @@ -57,6 +58,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) // kilocode_change start - Kilo HttpApi groups + .addHttpApi(BackgroundProcessApi) .addHttpApi(CommitMessageApi) .addHttpApi(EnhancePromptApi) .addHttpApi(IndexingApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts new file mode 100644 index 00000000000..e5df6f5abfe --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -0,0 +1,18 @@ +import { Schema } from "effect" + +export class ApiNotFoundError extends Schema.ErrorClass("NotFoundError")( + { + name: Schema.Literal("NotFoundError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 404 }, +) {} + +export function notFound(message: string) { + return new ApiNotFoundError({ + name: "NotFoundError", + data: { message }, + }) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 463ea1ae4c8..f2b0504a05f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" @@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({ mode: Vcs.Mode, }) +export class ApiVcsApplyError extends Schema.ErrorClass("VcsApplyError")( + { + name: Schema.Literal("VcsApplyError"), + data: Schema.Struct({ + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), + }), + }, + { httpApiStatus: 400 }, +) {} + export const InstancePaths = { dispose: "/instance/dispose", path: "/path", vcs: "/vcs", + vcsStatus: "/vcs/status", vcsDiff: "/vcs/diff", + vcsDiffRaw: "/vcs/diff/raw", + vcsApply: "/vcs/apply", command: "/command", agent: "/agent", skill: "/skill", @@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance") "Retrieve version control system (VCS) information for the current project, such as git branch.", }), ), + HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + success: described(Schema.Array(Vcs.FileStatus), "VCS status"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.status", + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + }), + ), HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { query: VcsDiffQuery, success: described(Schema.Array(Vcs.FileDiff), "VCS diff"), @@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance") description: "Retrieve the current git diff for the working tree or against the default branch.", }), ), + HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + success: described( + Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), + "Raw VCS diff", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.diff.raw", + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + }), + ), + HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + payload: Vcs.ApplyInput, + success: described(Vcs.ApplyResult, "VCS patch applied"), + error: ApiVcsApplyError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.apply", + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 3304ab9fbfd..ad513e0ad41 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -6,6 +6,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" @@ -64,7 +65,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, success: described(Pty.Info, "Session info"), - error: HttpApiError.NotFound, + error: ApiNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "pty.get", @@ -76,7 +77,7 @@ export const PtyApi = HttpApi.make("pty") params: { ptyID: PtyID }, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "pty.update", @@ -87,7 +88,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, success: described(Schema.Boolean, "Session removed"), - error: HttpApiError.NotFound, + error: ApiNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "pty.remove", @@ -98,7 +99,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), - error: [HttpApiError.Forbidden, HttpApiError.NotFound], + error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connectToken", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 52e71bb8b28..067ed28ca15 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/session" @@ -130,7 +131,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, success: described(Session.Info, "Get session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.get", @@ -175,7 +176,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: MessagesQuery, success: described(Schema.Array(MessageV2.WithParts), "List of messages"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.messages", @@ -186,7 +187,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, success: described(MessageV2.WithParts, "Message"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.message", @@ -208,7 +209,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, success: described(Schema.Boolean, "Successfully deleted session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.delete", @@ -220,7 +221,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.update", @@ -232,6 +233,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, payload: ForkPayload, success: described(Session.Info, "200"), + error: ApiNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", @@ -266,7 +268,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, success: described(Session.Info, "Successfully shared session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.share", @@ -277,7 +279,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, success: described(Session.Info, "Successfully unshared session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.unshare", @@ -289,7 +291,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.summarize", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 58d30b4c787..442e6565547 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@/util/schema" +import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({ export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }) +export const SessionPayload = Schema.Struct({ + sessionID: SessionID, +}) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: Schema.String, @@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({ export const SyncPaths = { start: `${root}/start`, replay: `${root}/replay`, + steal: `${root}/steal`, history: `${root}/history`, } as const @@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync") description: "Validate and replay a complete sync event history.", }), ), + HttpApiEndpoint.post("steal", SyncPaths.steal, { + payload: SessionPayload, + success: described(SessionPayload, "Session stolen into workspace"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.steal", + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + }), + ), HttpApiEndpoint.post("history", SyncPaths.history, { payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index efe73d95d1f..8ab43f66542 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,6 +4,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/tui" @@ -155,7 +156,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "tui.selectSession", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 08e9e044bb7..66422c13b6f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,29 +1,37 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" -import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct({ - ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), - extra: Schema.optional(Workspace.CreateInput.fields.extra), -}) -export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) -export const SessionRestoreResponse = Schema.Struct({ - total: NonNegativeInt, +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const WarpPayload = Schema.Struct({ + id: Schema.NullOr(Workspace.Info.fields.id), + sessionID: Workspace.SessionWarpInput.fields.sessionID, + copyChanges: Workspace.SessionWarpInput.fields.copyChanges, }) +export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceWarpError")( + { + name: Schema.Literal("WorkspaceWarpError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + export const WorkspacePaths = { adapters: `${root}/adapter`, list: root, status: `${root}/status`, remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, + warp: `${root}/warp`, } as const export const WorkspaceApi = HttpApi.make("workspace") @@ -79,16 +87,15 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Remove an existing workspace.", }), ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: described(SessionRestoreResponse, "Session replay started"), - error: HttpApiError.BadRequest, + HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + payload: WarpPayload, + success: described(HttpApiSchema.NoContent, "Session warped"), + error: [ApiWorkspaceWarpError, ApiVcsApplyError], }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", + identifier: "experimental.workspace.warp", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", }), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index c2a4503b481..50a7fecfa70 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -9,6 +9,7 @@ import { Skill } from "@/skill" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { ApiVcsApplyError } from "../groups/instance" import { markInstanceForDisposal } from "../lifecycle" export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => @@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" return { branch, default_branch } }) + const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () { + return yield* vcs.status() + }) + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { return yield* vcs.diff(ctx.query.mode) }) + const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () { + return yield* vcs.diffRaw() + }) + + const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) { + return yield* vcs.apply(ctx.payload).pipe( + Effect.mapError( + (error) => + new ApiVcsApplyError({ + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }), + ), + ) + }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { return yield* command.list() }) @@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("dispose", dispose) .handle("path", getPath) .handle("vcs", getVcs) + .handle("vcsStatus", getVcsStatus) .handle("vcsDiff", getVcsDiff) + .handle("vcsDiffRaw", getVcsDiffRaw) + .handle("vcsApply", applyVcs) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index e5ff300a2a0..7b8395d8095 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -15,6 +15,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" +import * as ApiError from "../errors" import { CursorQuery, Params, PtyPaths } from "../groups/pty" import { WebSocketTracker } from "../websocket-tracker" @@ -46,7 +47,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { const info = yield* pty.get(ctx.params.ptyID) - if (!info) return yield* new HttpApiError.NotFound({}) + if (!info) return yield* ApiError.notFound("Session not found") return info }) @@ -58,7 +59,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler ...ctx.payload, size: ctx.payload.size ? { ...ctx.payload.size } : undefined, }) - if (!info) return yield* new HttpApiError.NotFound({}) + if (!info) return yield* ApiError.notFound("Session not found") return info }) @@ -71,7 +72,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const request = yield* HttpServerRequest.HttpServerRequest if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) return yield* new HttpApiError.Forbidden({}) - if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found") return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts new file mode 100644 index 00000000000..98ac2b9ad6d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts @@ -0,0 +1,9 @@ +import type { NotFoundError as StorageNotFoundError } from "@/storage/storage" +import { Effect } from "effect" +import * as ApiError from "../errors" + +type StorageNotFound = InstanceType + +export function mapStorageNotFound(self: Effect.Effect) { + return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message))) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 1e8fe40b706..de1fdbab665 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -38,14 +38,7 @@ import { UpdatePayload, ViewedPayload, } from "../groups/session" - -const mapNotFound = (self: Effect.Effect) => - self.pipe( - Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), - Effect.catchDefect((error) => - NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error), - ), - ) +import * as SessionError from "./session-errors" export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { @@ -80,7 +73,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { - return yield* mapNotFound(session.get(ctx.params.sessionID)) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -102,51 +95,49 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - return yield* mapNotFound( - Effect.gen(function* () { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before - yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), - catch: () => new HttpApiError.BadRequest({}), - }) - } - if (ctx.query.limit === undefined || ctx.query.limit === 0) { - yield* session.get(ctx.params.sessionID) - return yield* session.messages({ sessionID: ctx.params.sessionID }) - } + if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before) { + const before = ctx.query.before + yield* Effect.try({ + try: () => MessageV2.cursor.decode(before), + catch: () => new HttpApiError.BadRequest({}), + }) + } + yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + if (ctx.query.limit === undefined || ctx.query.limit === 0) { + return yield* session.messages({ sessionID: ctx.params.sessionID }) + } - yield* session.get(ctx.params.sessionID) - const page = MessageV2.page({ - sessionID: ctx.params.sessionID, - limit: ctx.query.limit, - before: ctx.query.before, - }) - if (!page.cursor) return page.items + const page = MessageV2.page({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit, + before: ctx.query.before, + }) + if (!page.cursor) return page.items - const request = yield* HttpServerRequest.HttpServerRequest - // toURL() honors the Host + x-forwarded-proto headers, so the Link - // header echoes the real origin instead of a hard-coded localhost. - const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) - url.searchParams.set("limit", ctx.query.limit.toString()) - url.searchParams.set("before", page.cursor) - return HttpServerResponse.jsonUnsafe(page.items, { - headers: { - "Access-Control-Expose-Headers": "Link, X-Next-Cursor", - Link: `<${url.toString()}>; rel="next"`, - "X-Next-Cursor": page.cursor, - }, - }) - }), - ) + const request = yield* HttpServerRequest.HttpServerRequest + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) + url.searchParams.set("limit", ctx.query.limit.toString()) + url.searchParams.set("before", page.cursor) + return HttpServerResponse.jsonUnsafe(page.items, { + headers: { + "Access-Control-Expose-Headers": "Link, X-Next-Cursor", + Link: `<${url.toString()}>; rel="next"`, + "X-Next-Cursor": page.cursor, + }, + }) }) const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - return yield* mapNotFound( - Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })), + return yield* SessionError.mapStorageNotFound( + Effect.try({ + try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), + catch: (error) => error, + }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))), ) }) @@ -171,7 +162,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* session.remove(ctx.params.sessionID) + yield* SessionError.mapStorageNotFound(session.remove(ctx.params.sessionID)) return true }) @@ -179,7 +170,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof UpdatePayload.Type }) { - const current = yield* session.get(ctx.params.sessionID) + const current = yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) if (ctx.payload.title !== undefined) { yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } @@ -192,14 +183,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (ctx.payload.time?.archived !== undefined) { yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) } - return yield* session.get(ctx.params.sessionID) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ForkPayload.Type }) { - return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }) + return yield* SessionError.mapStorageNotFound( + session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), + ) }) const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -223,19 +216,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - return yield* session.get(ctx.params.sessionID) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - return yield* session.get(ctx.params.sessionID) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID)) + yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))) const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) const defaultAgent = yield* agentSvc.defaultAgent() const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index f4a2f315cd9..152d22f98e6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,5 +1,6 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" import { EventTable } from "@/sync/event.sql" @@ -12,7 +13,7 @@ import { or } from "drizzle-orm" import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { HistoryPayload, ReplayPayload } from "../groups/sync" +import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "server.sync" }) @@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl return { sessionID: source } }) + const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) { + const workspaceID = yield* InstanceState.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + yield* sync.run(Session.Event.Updated, { + sessionID: ctx.payload.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: ctx.payload.sessionID, + workspaceID, + }) + + return { sessionID: ctx.payload.sessionID } + }) + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return Database.use((db) => @@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl ) }) - return handlers.handle("start", start).handle("replay", replay).handle("history", history) + return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index cc85321685b..0ecebf451fe 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -1,13 +1,12 @@ import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" -import { SessionTable } from "@/session/session.sql" -import * as Database from "@/storage/db" -import { eq } from "drizzle-orm" +import { Session } from "@/session/session" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control" import { InstanceHttpApi } from "../api" import { CommandPayload, TuiPublishPayload } from "../groups/tui" +import * as SessionError from "./session-errors" const commandAliases = { session_new: "session.new", @@ -28,6 +27,7 @@ const commandAliases = { export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service + const session = yield* Session.Service const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) => bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type) @@ -98,12 +98,7 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler payload: typeof TuiEvent.SessionSelect.properties.Type }) { if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) - const row = yield* Effect.sync(() => - Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), - ), - ) - if (!row) return yield* new HttpApiError.NotFound({}) + yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID)) yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 570f355e575..d908eda9d1a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,10 +1,12 @@ import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Vcs } from "@/project/vcs" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, SessionRestorePayload } from "../groups/workspace" +import { ApiVcsApplyError } from "../groups/instance" +import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -39,16 +41,32 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace.remove(ctx.params.id) }) - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - return yield* workspace - .sessionRestore({ - workspaceID: ctx.params.id, + const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + yield* workspace + .sessionWarp({ + workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, + copyChanges: ctx.payload.copyChanges, }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + .pipe( + Effect.mapError((error) => { + if (error instanceof Vcs.PatchApplyError) { + return new ApiVcsApplyError({ + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }) + } + return new ApiWorkspaceWarpError({ + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }) + }), + ) }) return handlers @@ -57,6 +75,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("create", create) .handle("status", status) .handle("remove", remove) - .handle("sessionRestore", sessionRestore) + .handle("warp", warp) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 6de6d9c4fb1..017a70b91aa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -7,6 +7,7 @@ import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" +import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -178,7 +179,10 @@ function routeHttpApiWorkspace( const request = yield* HttpServerRequest.HttpServerRequest const sessionID = getWorkspaceRouteSessionID(requestURL(request)) const session = sessionID - ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( + Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)), + Effect.catchDefect(() => Effect.succeed(undefined)), + ) : undefined const plan = yield* planRequest(request, session?.workspaceID) return yield* routeWorkspace(client, effect, plan) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 8f87dc9627c..70d4e2b0df6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -92,7 +92,7 @@ const PathParameterSchemas = { const LegacyComponentDescriptions = { LogLevel: "Log level", - ServerConfig: "Server configuration for opencode serve and web commands", + ServerConfig: "Server configuration for the kilo serve command", // kilocode_change LayoutConfig: "@deprecated Always uses stretch layout.", } satisfies Record @@ -149,6 +149,16 @@ function matchLegacyOpenApi(input: Record) { if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } + if (path === "/experimental/workspace/warp" && method === "post") { + const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace( + "#/components/schemas/", + "", + ) + const properties = ref + ? spec.components?.schemas?.[ref]?.properties + : operation.requestBody.content?.["application/json"]?.schema?.properties + if (properties?.id) properties.id = { anyOf: [properties.id, { type: "null" }] } + } } for (const response of Object.values(operation.responses ?? {})) { for (const content of Object.values(response.content ?? {})) { @@ -524,6 +534,7 @@ function pathParameterSchema(route: string, name: string) { if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "processID" && route.includes(" /background-process/")) return { type: "string", pattern: "^bgp.*" } // kilocode_change if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } // /network/* reuses QuestionID (prefix "que"), not a separate brand. // kilocode_change diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 4758e90732a..6560b59e772 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { InstanceMiddleware } from "./middleware" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" import { register as registerKiloRoutes } from "@/kilocode/server/instance" // kilocode_change import { ExperimentalHttpApiServer } from "./httpapi/server" import { EventPaths } from "./httpapi/event" @@ -42,6 +42,7 @@ import { TuiPaths } from "./httpapi/groups/tui" import { WorkspacePaths } from "./httpapi/groups/workspace" import { register as registerKiloHttpApiRoutes } from "@/kilocode/server/httpapi/instance" // kilocode_change import type { CorsOptions } from "@/server/cors" +import { errors } from "@/server/error" export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() @@ -92,7 +93,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context)) app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context)) app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) @@ -162,7 +166,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) registerKiloHttpApiRoutes(app, handler, context) // kilocode_change } @@ -297,6 +301,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H return yield* vcs.diff(c.req.valid("query").mode) }), ) + .get( + "/vcs/status", + describeRoute({ + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + operationId: "vcs.status", + responses: { + 200: { + description: "VCS status", + content: { + "application/json": { + schema: resolver(Vcs.FileStatus.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.vcs.status", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.status() + }), + ) + .get( + "/vcs/diff/raw", + describeRoute({ + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + operationId: "vcs.diff.raw", + responses: { + 200: { + description: "Raw VCS diff", + content: { + "text/x-diff": { + schema: resolver(z.string()), + }, + }, + }, + }, + }), + async (c) => { + const patch = await runRequest( + "InstanceRoutes.vcs.diffRaw", + c, + Vcs.Service.use((vcs) => vcs.diffRaw()), + ) + return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" }) + }, + ) + .post( + "/vcs/apply", + describeRoute({ + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + operationId: "vcs.apply", + responses: { + 200: { + description: "VCS patch applied", + content: { + "application/json": { + schema: resolver(Vcs.ApplyResult.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Vcs.ApplyInput.zodObject), + async (c) => { + const result = await runRequest( + "InstanceRoutes.vcs.apply", + c, + Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (value) => ({ ok: true as const, value }), + }), + ), + ) + if (result.ok) return c.json(result.value) + return c.json( + { + name: "VcsApplyError", + data: { + message: result.error.message, + reason: result.error.reason, + }, + }, + 400, + ) + }, + ) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b7bf413d4ed..9894d8c8eec 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" +import { Session } from "@/session/session" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { SessionID } from "@/session/schema" const ReplayEvent = z.object({ id: z.string(), @@ -24,6 +27,9 @@ const ReplayEvent = z.object({ type: z.string(), data: z.record(z.string(), z.unknown()), }) +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) const log = Log.create({ service: "server.sync" }) @@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() => }) }, ) + .post( + "/steal", + describeRoute({ + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + operationId: "sync.steal", + responses: { + 200: { + description: "Session stolen into workspace", + content: { + "application/json": { + schema: resolver(SessionPayload), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + const workspaceID = WorkspaceContext.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + SyncEvent.run(Session.Event.Updated, { + sessionID: body.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: body.sessionID, + workspaceID, + }) + + return c.json({ + sessionID: body.sessionID, + }) + }, + ) .post( "/history", describeRoute({ diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index defd1c18705..b42a728e716 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Hono } from "hono" -import { DEFAULT_CSP, embeddedUI } from "../shared/ui" +import { embeddedUI, cspForHtml } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -14,8 +14,11 @@ export async function serveUI(request: Request) { if (await fs.exists(match)) { const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return new Response(new Uint8Array(await fs.readFile(match)), { headers }) + const body = new Uint8Array(await fs.readFile(match)) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) + } + return new Response(body, { headers }) } return Response.json({ error: "Not Found" }, { status: 404 }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5105ce768e1..50a32ad1729 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -108,10 +108,10 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) - .use(AuthMiddleware) - .use(LoggerMiddleware(backendAttributes)) - .use(CompressionMiddleware) .use(CorsMiddleware(opts)) + .use(LoggerMiddleware(backendAttributes)) + .use(AuthMiddleware) + .use(CompressionMiddleware) .route("/global", GlobalRoutes()) const runtime = adapter.create(app) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index aad49b8823b..e6ca19c126b 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -2,14 +2,25 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect } from "effect" import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createHash } from "node:crypto" const embeddedUIPromise = Flag.KILO_DISABLE_EMBEDDED_WEB_UI ? Promise.resolve(null) : // @ts-expect-error - generated file at build time import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) -export const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +export const csp = (hash = "") => + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` +export const DEFAULT_CSP = csp() + +export function themePreloadHash(body: string) { + return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +} + +export function cspForHtml(body: string) { + const match = themePreloadHash(body) + return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "") +} export function embeddedUI() { if (Flag.KILO_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) @@ -23,7 +34,9 @@ function notFound() { function embeddedUIResponse(file: string, body: Uint8Array) { const mime = AppFileSystem.mimeType(file) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) + } return HttpServerResponse.raw(body, { headers }) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 53b5dc23abc..0ff4877d87f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -958,15 +958,32 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } + // Anthropic adaptive thinking can persist assistant turns like: + // step-start, reasoning(signature), text(""), step-start, + // reasoning(signature). The empty text part is a structural separator, + // but it does not carry the signature metadata itself. Dropping it shifts + // signed thinking positions after step-start splitting/provider regrouping; + // keeping it as "" is filtered by the AI SDK and rejected by Anthropic. + // It is unclear whether this shape originates in our stream processing, + // a proxy, or a lower-level library, but preserving a non-empty separator + // here is the only safe replay point we have. + // Use a single space so the separator survives replay without changing + // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores + // the same signature under the bedrock metadata namespace. + const hasSignedReasoning = msg.parts.some((part) => { + if (part.type !== "reasoning") return false + return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + }) for (const part of msg.parts) { - // kilocode_change start - keep local UI warnings out of future prompts - if (part.type === "text" && !part.ignored) - // kilocode_change end + // kilocode_change - !part.ignored keeps local UI warnings out of future prompts + if (part.type === "text" && !part.ignored) { + const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", - text: part.text, + text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) + } if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", @@ -1212,6 +1229,32 @@ export function filterCompacted(msgs: Iterable) { completed.add(msg.info.parentID) } result.reverse() + const compactionIndex = result.findLastIndex( + (msg) => + msg.info.role === "user" && + msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), + ) + const compaction = result[compactionIndex] + const part = compaction?.parts.find( + (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, + ) + const summaryIndex = compaction + ? result.findIndex( + (msg, index) => + index > compactionIndex && + msg.info.role === "assistant" && + msg.info.summary && + msg.info.parentID === compaction.info.id, + ) + : -1 + const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 + if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { + return [ + ...result.slice(compactionIndex, summaryIndex + 1), + ...result.slice(tailIndex, compactionIndex), + ...result.slice(summaryIndex + 1), + ] + } return result } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f29d6b3d16b..1db62f2fd79 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -25,6 +25,7 @@ import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 @@ -228,6 +229,11 @@ export const layer: Layer.Layer< attachments: output.attachments, }, }) + // kilocode_change start - accepted suggest review actions tag following LLM completion telemetry + if (match.part.tool === "suggest") { + ctx.telemetry = KiloSessionProcessor.suggestionReviewTelemetry(output.metadata) ?? ctx.telemetry + } + // kilocode_change end yield* settleToolCall(toolCallID) }) @@ -508,9 +514,9 @@ export const layer: Layer.Layer< sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { - id: ctx.model.id, - providerID: ctx.model.providerID, - variant: input.assistantMessage.variant, + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), @@ -786,7 +792,7 @@ export const layer: Layer.Layer< EventV2.run(SessionEvent.Step.Failed.Sync, { sessionID: ctx.sessionID, error: { - type: error.name, + type: "unknown", message: errorMessage(e), }, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 4d6fa96a4ac..8938a9b91d6 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -151,11 +151,7 @@ export default [ SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { db.update(SessionTable) .set({ - model: { - id: data.id, - providerID: data.providerID, - variant: data.variant, - }, + model: data.model, time_updated: DateTime.toEpochMillis(data.timestamp), }) .where(eq(SessionTable.id, data.sessionID)) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 88c3f2b2af6..17748fa93f7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -8,7 +8,6 @@ import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kil import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change -import z from "zod" import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -64,6 +63,7 @@ import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" @@ -135,9 +135,8 @@ export const layer = Layer.effect( return yield* EffectBridge.make() }) const ops = Effect.fn("SessionPrompt.ops")(function* () { - const run = yield* runner() return { - cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), + cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input), } satisfies TaskPromptOps @@ -789,7 +788,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void const { msg, part, cwd } = yield* Effect.gen(function* () { const ctx = yield* InstanceState.context - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if (session.revert) { yield* revert.cleanup(session) } @@ -1023,9 +1022,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - id: info.model.modelID, - providerID: info.model.providerID, - variant: info.model.variant, + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + }, }) } @@ -1414,7 +1415,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) // kilocode_change start - persist queued prompts immediately while serializing each follow-up loop yield* KiloSessionPrompt.recoverDanglingAssistant({ sessionID: input.sessionID, status, sessions }) @@ -1476,9 +1477,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the let compactionAttempts = 0 // kilocode_change - cap compaction attempts per turn to avoid infinite loops const ctx = yield* InstanceState.context const slog = elog.with({ sessionID }) - let structured: unknown | undefined + let structured: unknown let step = 0 - const session = yield* sessions.get(sessionID) + const session = yield* sessions.get(sessionID).pipe(Effect.orDie) while (true) { yield* status.set(sessionID, { type: "busy" }) @@ -1505,15 +1506,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - // kilocode_change start - carry local review command marker into LLM telemetry - const telemetry = KiloSessionProcessor.extractReviewTelemetry( - msgs.findLast((m) => m.info.role === "user" && m.info.id === lastUser.id)?.parts ?? [], - ) - // kilocode_change end - const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, ) + // kilocode_change start - carry local review command marker into LLM telemetry + const telemetry = + KiloSessionProcessor.extractReviewTelemetry( + msgs.findLast((m) => m.info.role === "user" && m.info.id === lastUser.id)?.parts ?? [], + ) ?? KiloSessionProcessor.extractSuggestionReviewTelemetry(lastAssistantMsg?.parts ?? []) + // kilocode_change end + // kilocode_change start - keep provider-executed tools from forcing a re-loop // Some providers return "stop" even when the assistant message contains tool calls. // Keep the loop running so tool results can be sent back to the model. diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 8a527c8a285..f5fb9849258 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -44,7 +44,7 @@ export const layer = Layer.effect( yield* state.assertNotBusy(input.sessionID) const all = yield* sessions.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) let rev: Session.Info["revert"] const patches: Snapshot.Patch[] = [] @@ -77,12 +77,12 @@ export const layer = Layer.effect( // kilocode_change start - compute diffs BEFORE reverting files so the diff // reflects changes being undone (files on disk still have AI modifications) - const range = all.filter((msg) => msg.info.id >= rev!.messageID) + const range = all.filter((msg) => msg.info.id >= rev.messageID) const diffs = yield* summary.computeDiff({ messages: range }) // kilocode_change end yield* snap.revert(patches) - if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string) + if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) // kilocode_change start @@ -103,17 +103,17 @@ export const layer = Layer.effect( diffs: summaryDiffs, // kilocode_change }, }) - return yield* sessions.get(input.sessionID) + return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { log.info("unreverting", input) yield* state.assertNotBusy(input.sessionID) - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if (!session.revert) return session - if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!) + if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot) yield* sessions.clearRevert(input.sessionID) - return yield* sessions.get(input.sessionID) + return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index b499b157098..3b0023bc029 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -3,7 +3,6 @@ import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" -import z from "zod" import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -32,8 +31,10 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" // kilocode_change start - legacy promise helpers + kilocode extensions import { makeRuntime } from "@/effect/run-service" +import { BackgroundProcess } from "@/kilocode/background-process" import { KiloSession, kiloSessionFork } from "@/kilocode/session" import { fn } from "@/util/fn" +import { z } from "zod" // kilocode_change end import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" @@ -441,6 +442,8 @@ export class BusyError extends Error { } } +export type NotFound = InstanceType + export interface Interface { readonly list: (input?: ListInput) => Effect.Effect readonly create: (input?: { @@ -452,9 +455,9 @@ export interface Interface { platform?: string // kilocode_change - per-session platform override for telemetry attribution workspaceID?: WorkspaceID }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect - readonly get: (id: SessionID) => Effect.Effect + readonly get: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect @@ -468,7 +471,7 @@ export interface Interface { readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect + readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect @@ -559,13 +562,13 @@ export const layer: Layer.Layer d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) + if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) return fromRow(row) }) const list = Effect.fn("Session.list")(function* (input?: ListInput) { const ctx = yield* InstanceState.context - return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) })) + return Array.from(listByProject({ projectID: ctx.project.id, ...input })) }) // kilocode_change start - scope by project_id when instance context is available @@ -585,8 +588,8 @@ export const layer: Layer.Layer KiloSession.removeSession(sessionID)).pipe(Effect.ignore) KiloSession.clearPlatformOverride(sessionID) if (hasInstance) { + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore) void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) => app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(() => {}), ) diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/opencode/src/sync/event.sql.ts index b51b5a5dfeb..547a80f0f34 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/opencode/src/sync/event.sql.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), seq: integer().notNull(), + owner_id: text(), }) export const EventTable = sqliteTable("event", { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 142ce00d9f2..110812718a7 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -59,8 +59,11 @@ export interface Interface { data: Event["data"], options?: { publish?: boolean }, ) => Effect.Effect - readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect - readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect + readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { publish: boolean; ownerID?: string }, + ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect } @@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)( const row = Database.use((db) => db - .select({ seq: EventSequenceTable.seq }) + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) .get(), @@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)( const latest = row?.seq ?? -1 if (event.seq <= latest) return + if (row?.ownerID && row.ownerID !== options?.ownerID) { + return + } + const expected = latest + 1 if (event.seq !== expected) { throw new Error( @@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context }) + process(def, event, { publish, context, ownerID: options?.ownerID }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -263,7 +270,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext }, + options: { publish: boolean; context?: PublishContext; ownerID?: string }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -274,8 +281,6 @@ function process( throw new Error(`Projector not found for event: ${def.type}`) } - // idempotent: need to ignore any events already logged - Database.transaction((tx) => { projector(tx, event.data, event) @@ -284,6 +289,7 @@ function process( .values({ aggregate_id: event.aggregateID, seq: event.seq, + owner_id: options?.ownerID, }) .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, @@ -332,11 +338,11 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean }) { +export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replay(event, options)) } -export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { +export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replayAll(events, options)) } @@ -348,6 +354,16 @@ export function remove(aggregateID: string) { return runtime.runSync((sync) => sync.remove(aggregateID)) } +export function claim(aggregateID: string, ownerID: string) { + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ) +} + export function payloads() { return registry .entries() diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f39d0bee9ff..9bb2a571bfe 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,10 +9,11 @@ import { Config } from "@/config/config" import { KiloTask } from "../kilocode/tool/task" // kilocode_change import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change -import { Effect, Schema } from "effect" +import { Effect, Exit, Schema } from "effect" +import { EffectBridge } from "@/effect/bridge" export interface TaskPromptOps { - cancel(sessionID: SessionID): void + cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } @@ -138,17 +139,19 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + const runCancel = yield* EffectBridge.make() const messageID = MessageID.ascending() + const cancel = ops.cancel(nextSession.id) - function cancel() { - ops.cancel(nextSession.id) + function onAbort() { + runCancel.fork(cancel) } return yield* Effect.acquireUseRelease( // kilocode_change start - snapshot child cost so we propagate only the delta on resume (#6321) Effect.gen(function* () { - ctx.abort.addEventListener("abort", cancel) + ctx.abort.addEventListener("abort", onAbort) return yield* KiloCostPropagation.childCost(sessions, nextSession.id) }), // kilocode_change end @@ -190,12 +193,18 @@ export const TaskTool = Tool.define( } }), // kilocode_change start - propagate subagent cost delta to parent on every exit path (#6321) - (costBefore) => + (costBefore, exit) => Effect.gen(function* () { - ctx.abort.removeEventListener("abort", cancel) - const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id) - yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore) - }), + if (Exit.hasInterrupts(exit)) yield* cancel + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + ctx.abort.removeEventListener("abort", onAbort) + const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id) + yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore) + }), + ), + ), // kilocode_change end ) }) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 49f60e93119..ec900b44167 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string { return str.slice(0, len - 1) + "…" } +export function truncateLeft(str: string, len: number): string { + if (str.length <= len) return str + return "…" + str.slice(-(len - 1)) +} + export function truncateMiddle(str: string, maxLength: number = 35): string { if (str.length <= maxLength) return str diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts new file mode 100644 index 00000000000..8d0ce9174fa --- /dev/null +++ b/packages/opencode/src/v2/auth.ts @@ -0,0 +1,246 @@ +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "@opencode-ai/core/util/identifier" +import { NonNegativeInt, withStatics } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { AppFileSystem } from "@opencode-ai/core/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const AccountID = Schema.String.pipe( + Schema.brand("AccountID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type AccountID = typeof AccountID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "AuthV2.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Account extends Schema.Class("AuthV2.Account")({ + id: AccountID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type AuthError = AuthFileWriteError + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const accountID = AccountID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Account({ + id: accountID, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = accountID + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (accountID: AccountID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + active?: boolean + }) => Effect.Effect + readonly update: ( + accountID: AccountID, + updates: Partial>, + ) => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly activate: (accountID: AccountID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const file = path.join(global.data, "auth-v2.json") + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.KILO_AUTH_CONTENT) { + try { + return JSON.parse(process.env.KILO_AUTH_CONTENT) + } catch {} + } + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } + + if ("version" in raw && raw.version === 2) return raw as Writable + + const migrated = migrate(raw as Record) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe(yield* load()) + + const result: Interface = { + get: Effect.fn("AuthV2.get")(function* (accountID) { + return (yield* SynchronizedRef.get(state)).accounts[accountID] + }), + + all: Effect.fn("AuthV2.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("AuthV2.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("AuthV2.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("AuthV2.add")(function* (input) { + return yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = new Account({ + id: AccountID.make(Identifier.ascending()), + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: + (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) + ? { ...data.active, [input.serviceID]: account.id } + : data.active, + } + + yield* write(next) + return [account, next] as const + }), + ) + }), + + update: Effect.fn("AuthV2.update")(function* (accountID, updates) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const existing = data.accounts[accountID] + if (!existing) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [accountID]: new Account({ + id: accountID, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("AuthV2.remove")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) + delete active[accounts[accountID].serviceID] + delete accounts[accountID] + + const next = { ...data, accounts, active } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + activate: Effect.fn("AuthV2.activate")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = data.accounts[accountID] + if (!account) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as AuthV2 from "./auth" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts new file mode 100644 index 00000000000..db66199a59b --- /dev/null +++ b/packages/opencode/src/v2/model.ts @@ -0,0 +1,192 @@ +import { withStatics } from "@/util/schema" +import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" + +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) +export type ID = typeof ID.Type + +export const ProviderID = Schema.String.pipe( + Schema.brand("Model.ProviderID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ProviderID = typeof ProviderID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( + Schema.toTaggedUnion("type"), +) +export type Endpoint = typeof Endpoint.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("Model.Info")({ + id: ID, + providerID: ProviderID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) {} + +export function parse(input: string): { providerID: ProviderID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export interface Interface { + readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> + readonly add: (model: Info) => Effect.Effect + readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (provider: ProviderID) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/v2/Model") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let models = HashMap.empty() + + function key(providerID: ProviderID, modelID: ID) { + return `${providerID}/${modelID}` + } + + const result: Interface = { + get: Effect.fn("V2Model.get")(function* (providerID, modelID) { + return HashMap.get(models, key(providerID, modelID)) + }), + + add: Effect.fn("V2Model.add")(function* (model) { + models = HashMap.set(models, key(model.providerID, model.id), model) + }), + + remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { + models = HashMap.remove(models, key(providerID, modelID)) + }), + + all: Effect.fn("V2Model.all")(function* () { + return pipe( + models, + HashMap.toValues, + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + default: Effect.fn("V2Model.default")(function* () { + const all = yield* result.all() + return Option.fromUndefinedOr(all[0]) + }), + + small: Effect.fn("V2Model.small")(function* (providerID) { + const all = yield* result.all() + const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) + return Option.fromUndefinedOr(match) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer + +export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 47938dcbed0..7c768bd551a 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } import { ToolOutput } from "./tool-output" -import { ModelID, ProviderID } from "@/provider/schema" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -22,10 +22,13 @@ const Base = { sessionID: SessionID, } -const Error = Schema.Struct({ - type: Schema.String, +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), message: Schema.String, +}).annotate({ + identifier: "Session.Error.Unknown", }) +export type UnknownError = Schema.Schema.Type export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", @@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({ version: 1, schema: { ...Base, - id: ModelID, - providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), + model: Modelv2.Ref, }, }) export type ModelSwitched = Schema.Schema.Type @@ -103,11 +104,7 @@ export namespace Step { schema: { ...Base, agent: Schema.String, - model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - variant: Schema.String.pipe(Schema.optional), - }), + model: Modelv2.Ref, snapshot: Schema.String.pipe(Schema.optional), }, }) @@ -139,7 +136,7 @@ export namespace Step { aggregate: "sessionID", schema: { ...Base, - error: Error, + error: UnknownError, }, }) export type Failed = Schema.Schema.Type @@ -296,7 +293,7 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - error: Error, + error: UnknownError, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index d5d5aac7b7f..80ecb1011eb 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -109,11 +109,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve id: event.id, type: "model-switched", metadata: event.metadata, - model: { - id: event.data.id, - providerID: event.data.providerID, - variant: event.data.variant, - }, + model: event.data.model, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 94f6b1cac27..024e28c4504 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event" import { EventV2 } from "./event" import { ToolOutput } from "./tool-output" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class("Session.Message. export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ ...Base, type: Schema.Literal("model-switched"), - model: Schema.Struct({ - id: SessionEvent.ModelSwitched.fields.data.fields.id, - providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, - variant: SessionEvent.ModelSwitched.fields.data.fields.variant, - }), + model: Modelv2.Ref, }) {} export class User extends Schema.Class("Session.Message.User")({ @@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class("Session.Messag input: Schema.Record(Schema.String, Schema.Unknown), content: ToolOutput.Content.pipe(Schema.Array), structured: ToolOutput.Structured, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: SessionEvent.UnknownError, }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1f4cbcf1e0c..bb86f039b23 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceID } from "@/control-plane/schema" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import * as Database from "@/storage/db" -import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" import { SessionMessage } from "./session-message" import type { Prompt } from "./session-prompt" import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@/util/schema" +import { Modelv2 } from "./model" -export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", }) export type Delivery = Schema.Schema.Type @@ -27,11 +27,7 @@ export class Info extends Schema.Class("Session.Info")({ workspaceID: optionalOmitUndefined(WorkspaceID), path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), - model: Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: optionalOmitUndefined(Schema.String), - }).pipe(optionalOmitUndefined), + model: Modelv2.Ref.pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, @@ -53,7 +49,18 @@ export class Info extends Schema.Class("Session.Info")({ */ }) {} +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: SessionID, +}) {} + export interface Interface { + readonly create: (input?: { + agent?: string + model?: Modelv2.Ref + parentID?: SessionID + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly get: (sessionID: SessionID) => Effect.Effect readonly list: (input: { limit?: number order?: "asc" | "desc" @@ -88,13 +95,15 @@ export interface Interface { }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly subagent: (input: { + id?: EventV2.ID + parentID: SessionID + prompt: Prompt + agent: string + model?: Modelv2.Ref + }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { - sessionID: SessionID - id: ModelID - providerID: ProviderID - variant?: string - }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } @@ -120,9 +129,9 @@ export const layer = Layer.effect( agent: row.agent ?? undefined, model: row.model ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), - variant: row.model.variant, + id: Modelv2.ID.make(row.model.id), + providerID: Modelv2.ProviderID.make(row.model.providerID), + variant: Modelv2.VariantID.make(row.model.variant ?? "default"), } : undefined, time: { @@ -134,6 +143,14 @@ export const layer = Layer.effect( } const result: Interface = { + create: Effect.fn("V2Session.create")(function* (_input) { + return {} as any + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), list: Effect.fn("V2Session.list")(function* (input) { const direction = input.cursor?.direction ?? "next" let order = input.order ?? "desc" @@ -262,11 +279,30 @@ export const layer = Layer.effect( EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), - id: input.id, - providerID: input.providerID, - variant: input.variant, + model: input.model, }) }), + subagent: Effect.fn("V2Session.subagent")(function* (input) { + const parent = yield* result.get(input.parentID) + const session = yield* result.create({ + agent: input.agent, + model: input.model, + parentID: input.parentID, + workspaceID: parent.workspaceID, + }) + yield* result.prompt({ + prompt: input.prompt, + sessionID: session.id, + }) + yield* Effect.gen(function* () { + yield* result.wait(session.id) + const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + const assistant = messages.find((msg) => msg.type === "assistant") + if (!assistant) return + const text = assistant.content.findLast((part) => part.type === "text") + if (!text) return + }).pipe(Effect.forkChild()) + }), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), } diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts index 9fa67de8294..68414fbee58 100644 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ b/packages/opencode/test/acp/agent-interface.test.ts @@ -22,9 +22,10 @@ const _typeCheck: _AssertAgentImplementsACPAgent = true describe("acp.agent interface compliance", () => { // Extract method names from the ACPAgent interface type type ACPAgentMethods = keyof ACPAgent + type ACPRuntimeMethod = ACPAgentMethods | "resumeSession" | "closeSession" // kilocode_change // Methods that the SDK's router explicitly checks for at runtime - const sdkCheckedMethods: ACPAgentMethods[] = [ + const sdkCheckedMethods: ACPRuntimeMethod[] = [ // kilocode_change // Required "initialize", "newSession", @@ -34,10 +35,11 @@ describe("acp.agent interface compliance", () => { "loadSession", "setSessionMode", "authenticate", - // Unstable - SDK checks these with unstable_ prefix + // Capability-gated methods checked by the SDK router "listSessions", + "resumeSession", + "closeSession", "unstable_forkSession", - "unstable_resumeSession", "unstable_setSessionModel", ] diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts new file mode 100644 index 00000000000..a32dc611254 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create" + +describe("recentConnectedWorkspaces", () => { + test("returns unique connected workspaces after filtering missing and inactive entries", () => { + const workspaces = [ + { id: "wrk_a", name: "alpha" }, + { id: "wrk_b", name: "beta" }, + { id: "wrk_c", name: "gamma" }, + { id: "wrk_d", name: "delta" }, + { id: "wrk_e", name: "epsilon" }, + ] + const status = { + wrk_a: "connected", + wrk_b: "disconnected", + wrk_c: "error", + wrk_d: "connected", + wrk_e: "connected", + } as const + + const { recent } = recentConnectedWorkspaces({ + sessions: [ + { time: { updated: 900 } }, + { workspaceID: "wrk_b", time: { updated: 800 } }, + { workspaceID: "wrk_a", time: { updated: 700 } }, + { workspaceID: "wrk_a", time: { updated: 600 } }, + { workspaceID: "wrk_missing", time: { updated: 500 } }, + { workspaceID: "wrk_c", time: { updated: 400 } }, + { workspaceID: "wrk_d", time: { updated: 300 } }, + { workspaceID: "wrk_e", time: { updated: 200 } }, + ], + get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + status: (workspaceID) => status[workspaceID as keyof typeof status], + }) + + expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) + }) + + test("omits the active workspace before limiting recent workspaces", () => { + const workspaces = [ + { id: "wrk_a", name: "alpha" }, + { id: "wrk_b", name: "beta" }, + { id: "wrk_c", name: "gamma" }, + { id: "wrk_d", name: "delta" }, + ] + + const { recent, hasMore } = recentConnectedWorkspaces({ + sessions: [ + { workspaceID: "wrk_a", time: { updated: 400 } }, + { workspaceID: "wrk_b", time: { updated: 300 } }, + { workspaceID: "wrk_c", time: { updated: 200 } }, + { workspaceID: "wrk_d", time: { updated: 100 } }, + ], + get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + status: () => "connected", + limit: 3, + omitWorkspaceID: "wrk_a", + }) + + expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"]) + expect(hasMore).toBe(false) + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/provider-options.test.ts b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts new file mode 100644 index 00000000000..39d63983793 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider" + +describe("providerOptions", () => { + test("includes a synthetic Other option for custom providers", () => { + expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({ + title: "Other", + description: "Custom provider", + category: "Providers", + }) + }) + + test("does not use Other as the generic provider category", () => { + expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers") + }) + + test("does not collide with a configured provider named other", () => { + const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value) + expect(new Set(values).size).toBe(values.length) + }) + + test("normalizes and validates custom provider ids", () => { + expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider") + expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider") + expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider") + expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined() + expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index 881c8db11b4..b3d74be6eff 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) { } as unknown as typeof WebSocket } +function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") { + socket.message( + JSON.stringify({ + jsonrpc: "2.0", + method: "selection_changed", + params: { + text, + filePath, + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + }), + ) +} + +function expectedSelection(filePath: string, text = "foo") { + return { + filePath, + source: "websocket" as const, + ranges: [ + { + text, + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], + } +} + test("useEditorContext reconnect switches editor server by session directory", async () => { await using tmp = await tmpdir() const startupDirectory = path.join(tmp.path, "startup") @@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a await nextTick() expect(firstSocket.closed).toBeFalse() + sendSelection(firstSocket, path.join(startupDirectory, "file.ts")) + + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + expect(mounted.editor.labelState()).toBe("pending") mounted.editor.reconnect(sessionDirectory) await nextTick() expect(firstSocket.closed).toBeTrue() expect(secondSocket.closed).toBeFalse() + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") mounted.dispose() }) @@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => { mounted.dispose() }) -test("useEditorContext resets selection when reconnecting", async () => { +test("useEditorContext clears selection when reconnecting", async () => { await using tmp = await tmpdir() const startupDirectory = path.join(tmp.path, "startup") const ideDirectory = path.join(tmp.path, ".claude", "ide") @@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => { }, }), ) - socket.message( - JSON.stringify({ - jsonrpc: "2.0", - method: "selection_changed", - params: { - text: "foo", - filePath: path.join(startupDirectory, "file.ts"), - selection: { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - }, - }, - }), - ) + sendSelection(socket, path.join(startupDirectory, "file.ts")) expect(mounted.editor.connected()).toBeTrue() expect(mounted.editor.server()).toEqual({ protocolVersion: "2025-11-25", serverInfo: { name: "test", version: "0.0.0" }, }) - expect(mounted.editor.selection()).toEqual({ - filePath: path.join(startupDirectory, "file.ts"), - source: "websocket", - ranges: [ - { - text: "foo", - selection: { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - }, - }, - ], - }) + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + expect(mounted.editor.labelState()).toBe("pending") + mounted.editor.markSelectionSent() + expect(mounted.editor.labelState()).toBe("sent") mounted.editor.reconnect(startupDirectory) expect(socket.closed).toBeFalse() expect(mounted.editor.connected()).toBeTrue() expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") + + mounted.dispose() +}) + +test("useEditorContext preserves selection for the next reconnect when requested", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.KILO_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:3001") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + sendSelection(socket, path.join(startupDirectory, "file.ts")) + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + + mounted.editor.markSelectionSent() + mounted.editor.preserveSelectionFromNewSession() + mounted.editor.reconnect(startupDirectory) + + expect(socket.closed).toBeFalse() + expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts"))) + expect(mounted.editor.labelState()).toBe("sent") + + mounted.editor.reconnect(startupDirectory) + + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") mounted.dispose() }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 69ad71043d7..51b4076e6e2 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1779,6 +1779,73 @@ test("Effect config parser preserves permission order while rejecting unknown to // MCP config merging tests +// kilocode_change start - regression for `env` alias on local MCP entries +test("local mcp accepts `env` as an alias for `environment`", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + mcp: { + context7: { + type: "local", + command: ["npx", "-y", "@upstash/context7-mcp"], + env: { CONTEXT7_API_KEY: "test-key" }, + enabled: true, + }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.mcp?.context7).toEqual({ + type: "local", + command: ["npx", "-y", "@upstash/context7-mcp"], + environment: { CONTEXT7_API_KEY: "test-key" }, + enabled: true, + }) + }, + }) +}) + +test("local mcp prefers `environment` over `env` when both are present", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + mcp: { + context7: { + type: "local", + command: ["npx", "-y", "@upstash/context7-mcp"], + environment: { CONTEXT7_API_KEY: "from-environment" }, + env: { CONTEXT7_API_KEY: "from-env" }, + }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.mcp?.context7).toEqual({ + type: "local", + command: ["npx", "-y", "@upstash/context7-mcp"], + environment: { CONTEXT7_API_KEY: "from-environment" }, + }) + }, + }) +}) +// kilocode_change end + test("project config can override MCP server enabled status", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -2049,6 +2116,83 @@ test("wellknown URL with trailing slash is normalized", async () => { } }) +test("wellknown remote_config supports templated env vars in headers", async () => { + const originalFetch = globalThis.fetch + const originalToken = process.env.TEST_TOKEN + let wellknownFetchedUrl: string | undefined + let remoteFetchedUrl: string | undefined + let remoteHeaders: HeadersInit | undefined + globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { + const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url + if (urlStr.includes(".well-known/opencode")) { + wellknownFetchedUrl = urlStr + return Promise.resolve( + new Response( + JSON.stringify({ + remote_config: { + url: "https://config.example.com/opencode.json", + headers: { + Authorization: "Bearer {env:TEST_TOKEN}", + }, + }, + }), + { status: 200 }, + ), + ) + } + if (urlStr.includes("config.example.com")) { + remoteFetchedUrl = urlStr + remoteHeaders = init?.headers + return Promise.resolve( + new Response( + JSON.stringify({ + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, + }), + { status: 200 }, + ), + ) + } + return originalFetch(url, init) + }) as unknown as typeof fetch + + const fakeAuth = Layer.mock(Auth.Service)({ + all: () => + Effect.succeed({ + "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + }), + }) + + const layer = Config.layer.pipe( + Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(fakeAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), + ) + + try { + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode") + expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json") + expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" }) + expect(config.mcp?.confluence?.enabled).toBe(true) + }), + ), + { git: true }, + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) + } finally { + globalThis.fetch = originalFetch + if (originalToken === undefined) delete process.env.TEST_TOKEN + else process.env.TEST_TOKEN = originalToken + } +}) + describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index ae2eb102f26..cb5c9323457 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { $ } from "bun" import fs from "node:fs/promises" import Http from "node:http" import path from "node:path" @@ -6,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { asc, eq } from "drizzle-orm" +import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" @@ -16,11 +17,10 @@ import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" -import { SessionID, MessageID, PartID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" -import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -30,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as WorkspaceOld from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - WorkspaceOld.defaultLayer, + WorkspaceOld.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), + ), SessionNs.defaultLayer, ) const it = testEffect(testServerLayer) @@ -108,11 +113,23 @@ async function withInstance(fn: (dir: string) => T | Promise) { }) } +async function initGitRepo(dir: string) { + await fs.mkdir(dir, { recursive: true }) + await $`git init`.cwd(dir).quiet() + await $`git config core.fsmonitor false`.cwd(dir).quiet() + await $`git config commit.gpgsign false`.cwd(dir).quiet() + await $`git config user.email "test@opencode.test"`.cwd(dir).quiet() + await $`git config user.name "Test"`.cwd(dir).quiet() + await fs.writeFile(path.join(dir, "tracked.txt"), "base\n") + await $`git add tracked.txt`.cwd(dir).quiet() + await $`git commit -m "base"`.cwd(dir).quiet() +} + const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) +const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) const listWorkspaces = (project: Parameters[0]) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) @@ -317,48 +334,24 @@ function sessionSequence(sessionID: SessionID) { )?.seq } -function eventRows(sessionID: SessionID) { +function sessionSequenceOwner(sessionID: SessionID) { return Database.use((db) => db - .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), + )?.ownerID } function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -function replaceSessionEvents(sessionID: SessionID, count: number) { - Database.use((db) => { - db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run() - if (count === 0) return - - db.insert(EventSequenceTable) - .values({ aggregate_id: sessionID, seq: count - 1 }) - .run() - db.insert(EventTable) - .values( - Array.from({ length: count }, (_, i) => ({ - id: `evt_${unique(`manual-${i}`)}`, - aggregate_id: sessionID, - seq: i, - type: sessionUpdatedType(), - data: { sessionID, info: { title: `manual ${i}` } }, - })), - ) - .run() - }) -} - describe("workspace-old schemas and exports", () => { test("keeps the historical event type names", () => { expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore") expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") }) @@ -375,17 +368,6 @@ describe("workspace-old schemas and exports", () => { expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) - - test("validates session restore input", () => { - const input = { - workspaceID: WorkspaceID.ascending("wrk_schema_restore"), - sessionID: SessionID.descending("ses_schema_restore"), - } - - expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow() - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow() - }) }) describe("workspace-old CRUD", () => { @@ -651,6 +633,176 @@ describe("workspace-old CRUD", () => { expect(await getWorkspace(info.id)).toBeUndefined() }) }) + + test("sessionWarp moves a session into a local workspace and claims ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-prev-local") + const targetType = unique("warp-target-local") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBe(target.id) + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }) + }) + + test("sessionWarp applies source workspace patch to local target workspace", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-patch-prev-local") + const targetType = unique("warp-patch-target-local") + const previousDir = path.join(dir, "warp-patch-prev-local") + const targetDir = path.join(dir, "warp-patch-target-local") + await initGitRepo(previousDir) + await initGitRepo(targetDir) + await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n") + await fs.writeFile(path.join(previousDir, "new.txt"), "new\n") + + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + + expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n") + expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n") + }) + }) + + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-detach-local") + const previous = workspaceInfo(Instance.project.id, previousType) + insertWorkspace(previous) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id) + }) + }) + + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { + const calls: FetchCall[] = [] + let historySessionID: SessionID | undefined + let historyNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/warp-source/sync/history") { + return yield* HttpServerResponse.json([ + { + id: `evt_${unique("warp-source-history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from source history" } }, + }, + ]) + } + if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch") + if (call.url.pathname === "/warp-target/sync/replay") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/sync/steal") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true }) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const previousType = unique("warp-remote-source") + const targetType = unique("warp-remote-target") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" }) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) + registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + const session = yield* sessionSvc.create({}) + attachSessionToWorkspace(session.id, previous.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + + expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ + "POST /warp-source/sync/history", + "GET /warp-source/vcs/diff/raw", + "POST /warp-target/vcs/apply", + "POST /warp-target/sync/replay", + "POST /warp-target/sync/steal", + ]) + expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) + expect(calls[2].json).toEqual({ patch: "remote patch" }) + expect(calls[3].json).toMatchObject({ + directory: "remote-target-dir", + events: [ + { + aggregateID: session.id, + seq: 0, + type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version), + }, + { + aggregateID: session.id, + seq: historyNextSeq, + type: sessionUpdatedType(), + }, + ], + }) + expect(calls[4].json).toEqual({ sessionID: session.id }) + expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }), + { git: true }, + ) + }) + }) }) describe("workspace-old sync state", () => { @@ -958,7 +1110,7 @@ describe("workspace-old sync state", () => { yield* eventuallyEffect( Effect.gen(function* () { - expect((yield* sessionSvc.get(session.id)).title).toBe("from history") + expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history") }), ) expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }]) @@ -1106,7 +1258,7 @@ describe("workspace-old sync state", () => { yield* eventuallyEffect( Effect.gen(function* () { - expect((yield* sessionSvc.get(session.id)).title).toBe("from sse") + expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse") }), ) expect( @@ -1215,313 +1367,3 @@ describe("workspace-old waitForSync", () => { }) }, 7000) }) - -describe("workspace-old sessionRestore", () => { - test("throws when the workspace is missing", async () => { - await withInstance(async () => { - await expect( - restoreWorkspaceSession({ - workspaceID: WorkspaceID.ascending("wrk_restore_missing"), - sessionID: SessionID.descending("ses_restore_missing_workspace"), - }), - ).rejects.toThrow("Workspace not found: wrk_restore_missing") - }) - }) - - test("throws when switching a missing session fails", async () => { - await withInstance(async (dir) => { - const type = unique("restore-missing-session") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - - await expect( - restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), - ).rejects.toThrow("NotFoundError") - await removeWorkspace(info.id) - }) - }) - - it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - const call = { - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - } - if (call.url.pathname === "/restore/sync/replay") { - replay.push(call) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - } - return HttpServerResponse.text("unexpected", { status: 500 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter( - Instance.project.id, - type, - remoteAdapter(`${url}/restore/?ignored=1#hash`, { - directory: dir, - headers: { authorization: "Bearer restore" }, - }).adapter, - ) - const session = yield* sessionSvc.create({ title: "restore remote" }) - replaceSessionEvents(session.id, 24) - - const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }) - - expect(result).toEqual({ total: 3 }) - expect(replay).toHaveLength(3) - expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([ - "/restore/sync/replay", - "/restore/sync/replay", - "/restore/sync/replay", - ]) - expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true) - expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true) - expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5]) - expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir]) - expect( - replay.flatMap((call) => - (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq), - ), - ).toEqual(Array.from({ length: 25 }, (_, i) => i)) - expect( - (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1), - ).toMatchObject({ - seq: 24, - type: sessionUpdatedType(), - data: { sessionID: session.id, info: { workspaceID: info.id } }, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("remote restore sends an empty directory string when the workspace directory is null", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - () => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-null-dir") - const info = workspaceInfo(Instance.project.id, type, { directory: null }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) - const session = yield* sessionSvc.create({ title: "null dir" }) - replaceSessionEvents(session.id, 0) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - expect((replay[0].json as { directory: string }).directory).toBe("") - expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) - - it.live("remote restore failures include status and body and do not emit completed batch progress", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.text("replay failed", { status: 503 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote-fail") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "restore fail" }) - replaceSessionEvents(session.id, 11) - - const error = yield* Effect.flip( - workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }), - ) - expect((error as Error).message).toContain( - `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`, - ) - - expect(replay).toHaveLength(1) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("local restore replays batches and emits progress", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-local") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - const session = yield* sessionSvc.create({ title: "restore local" }) - replaceSessionEvents(session.id, 20) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 3, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ), - ) - - it.live("session restore includes real message and part events in sequence order", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-real-events") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "real events" }) - for (let i = 0; i < 3; i++) { - const msg = yield* sessionSvc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* sessionSvc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: msg.id, - type: "text", - text: `message ${i}`, - }) - } - const before = eventRows(session.id) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - - const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events - expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1]) - expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type)) - expect(posted.at(-1)?.type).toBe(sessionUpdatedType()) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) -}) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 4bc067fcb7d..79f1583c814 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -285,6 +285,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { count: opts.state?.session?.count ?? (() => 0), diff: opts.state?.session?.diff ?? (() => []), todo: opts.state?.session?.todo ?? (() => []), + processes: opts.state?.session?.processes ?? (() => []), // kilocode_change messages: opts.state?.session?.messages ?? (() => []), status: opts.state?.session?.status ?? (() => undefined), permission: opts.state?.session?.permission ?? (() => []), diff --git a/packages/opencode/test/kilocode/background-process-tool.test.ts b/packages/opencode/test/kilocode/background-process-tool.test.ts new file mode 100644 index 00000000000..89b4cff3199 --- /dev/null +++ b/packages/opencode/test/kilocode/background-process-tool.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { Result, Schema } from "effect" +import { Params } from "@/kilocode/tool/background-process" +import { toJsonSchema } from "@/util/effect-zod" + +const accepts = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Params)(input)) + +describe("BackgroundProcessTool", () => { + test("emits a root object JSON schema", () => { + const json = toJsonSchema(Params) as { type?: unknown; anyOf?: unknown; properties?: Record } + + expect(json.type).toBe("object") + expect(json.anyOf).toBeUndefined() + expect(json.properties?.action).toEqual( + expect.objectContaining({ enum: ["start", "list", "status", "logs", "stop", "restart"] }), + ) + }) + + test("validates action-specific required fields", () => { + expect(accepts({ action: "list" })).toBe(true) + expect(accepts({ action: "start", command: "bun run dev", ready: { pattern: "ready" } })).toBe(true) + expect(accepts({ action: "start" })).toBe(false) + expect(accepts({ action: "stop", id: "bgp01" })).toBe(true) + expect(accepts({ action: "stop" })).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts new file mode 100644 index 00000000000..911f5ac8a88 --- /dev/null +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -0,0 +1,156 @@ +import { describe, expect } from "bun:test" +import { Bus } from "@/bus" +import { BackgroundProcess } from "@/kilocode/background-process" +import { SessionID } from "@/session/schema" +import { Shell } from "@/shell/shell" +import { Effect } from "effect" +import path from "path" +import { TestInstance } from "../fixture/fixture" +import { it } from "../lib/effect" + +function quote(input: string) { + const value = input.replaceAll("\\", "/") + if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"` + return `'${value.replaceAll("'", "'\\''")}'` +} + +async function script(dir: string, name: string, source: string) { + const file = path.join(dir, name) + await Bun.write(file, source) + const bin = quote(process.execPath) + const arg = quote(file) + if (Shell.ps(Shell.acceptable())) return `& ${bin} ${arg}` + return `${bin} ${arg}` +} + +function update(sessionID: SessionID) { + const state: { off?: () => void; timer?: ReturnType } = {} + const promise = new Promise((resolve, reject) => { + state.timer = setTimeout(() => { + state.off?.() + reject(new Error("timed out waiting for process update")) + }, 5_000) + state.off = Bus.subscribe(BackgroundProcess.Event.Updated, (event) => { + const info = event.properties.info + if (info.sessionID !== sessionID) return + if (!info.output.includes("tick")) return + state.off?.() + if (state.timer) clearTimeout(state.timer) + resolve(info) + }) + }) + return { + promise, + dispose() { + state.off?.() + if (state.timer) clearTimeout(state.timer) + }, + } +} + +describe("BackgroundProcess", () => { + it.instance("starts, reports readiness, and stops a process", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const command = yield* Effect.promise(() => + script( + test.directory, + "ready.mjs", + `console.log("ready") +setInterval(() => {}, 1_000) +`, + ), + ) + + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + description: "test server", + ready: { pattern: "ready", timeout: 5_000 }, + }), + ) + + expect(info.status).toBe("ready") + expect(info.output).toContain("ready") + + const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) + expect(list.map((item) => item.id)).toContain(info.id) + + const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id)) + expect(stopped?.status).toBe("stopped") + if (process.platform !== "win32") { + expect(stopped?.exitCode).toBeUndefined() + expect(stopped?.signal).toBe("SIGTERM") + } + + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + const next = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) + expect(next).toEqual([]) + }), + ) + + it.instance("publishes output updates from process callbacks", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const command = yield* Effect.promise(() => + script( + test.directory, + "tick.mjs", + `console.log("ready") +setTimeout(() => console.log("tick"), 200) +setInterval(() => {}, 1_000) +`, + ), + ) + const wait = update(sessionID) + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + ready: { pattern: "ready", timeout: 5_000 }, + }), + ) + + try { + const event = yield* Effect.promise(() => wait.promise) + expect(event.id).toBe(info.id) + expect(event.output).toContain("tick") + } finally { + wait.dispose() + yield* Effect.promise(() => BackgroundProcess.stop(info.id)) + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + } + }), + ) + + it.instance("rejects invalid readiness patterns before launching", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + + const err = yield* Effect.promise(async () => { + try { + await BackgroundProcess.start({ + sessionID, + command: "printf 'ready\n'", + cwd: test.directory, + ready: { pattern: "[", timeout: 1_000 }, + }) + } catch (err) { + return err + } + }) + + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("Invalid ready pattern") + + const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) + expect(list).toEqual([]) + }), + ) +}) diff --git a/packages/opencode/test/kilocode/command-branding.test.ts b/packages/opencode/test/kilocode/command-branding.test.ts new file mode 100644 index 00000000000..a4d9ddc76d5 --- /dev/null +++ b/packages/opencode/test/kilocode/command-branding.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +const root = path.join(__dirname, "..", "..") + +const files = [ + "src/cli/cmd/tui/feature-plugins/home/tips-view.tsx", + "src/cli/cmd/run.ts", + "src/config/config.ts", + "src/server/routes/instance/httpapi/public.ts", + "src/mcp/index.ts", +] + +const command = /opencode\s+(--[a-z-]+|run|serve|auth|upgrade|agent|github|mcp)\b/g + +describe("Kilo command branding", () => { + test("user-facing command help uses the `kilo` binary name", async () => { + const results = await Promise.all( + files.map(async (file) => ({ + file, + matches: [...(await Bun.file(path.join(root, file)).text()).matchAll(command)].map((match) => match[0]), + })), + ) + + expect(results.filter((result) => result.matches.length > 0)).toEqual([]) + }) +}) diff --git a/packages/opencode/test/kilocode/logo.test.ts b/packages/opencode/test/kilocode/logo.test.ts index a936581aa71..91c1a8f067c 100644 --- a/packages/opencode/test/kilocode/logo.test.ts +++ b/packages/opencode/test/kilocode/logo.test.ts @@ -2,15 +2,23 @@ import { describe, expect, test } from "bun:test" import { plain, session, supports, tui } from "../../src/kilocode/cli/logo" describe("kilocode logo", () => { - test("falls back on remote terminals", () => { - expect(supports({ SSH_TTY: "/dev/pts/0" }, "linux")).toBe(false) - expect(supports({ SSH_CLIENT: "127.0.0.1 12345 22" }, "linux")).toBe(false) - expect(supports({ SSH_CONNECTION: "127.0.0.1 12345 127.0.0.1 22" }, "linux")).toBe(false) + test("allows remote terminals", () => { + expect(supports({ SSH_TTY: "/dev/pts/0" }, "linux")).toBe(true) + expect(supports({ SSH_CLIENT: "127.0.0.1 12345 22" }, "linux")).toBe(true) + expect(supports({ SSH_CONNECTION: "127.0.0.1 12345 127.0.0.1 22" }, "linux")).toBe(true) }) - test("allows Windows Terminal locally", () => { - expect(supports({}, "win32")).toBe(true) - expect(supports({ WT_SESSION: "session" }, "linux")).toBe(true) + test("falls back on old Windows terminals", () => { + expect(supports({}, "win32")).toBe(false) + expect(supports({ ANSICON: "1" }, "win32")).toBe(false) + expect(supports({ ConEmuPID: "123" }, "win32")).toBe(false) + }) + + test("allows modern Windows terminals", () => { + expect(supports({ WT_SESSION: "session" }, "win32")).toBe(true) + expect(supports({ TERM_PROGRAM: "vscode" }, "win32")).toBe(true) + expect(supports({ WEZTERM_PANE: "1" }, "win32")).toBe(true) + expect(supports({ TERM_PROGRAM: "WezTerm" }, "win32")).toBe(true) }) test("allows an override", () => { @@ -20,12 +28,12 @@ describe("kilocode logo", () => { test("uses modern and fallback logo variants", () => { expect(tui({ KILO_UNICODE_LOGO: "1" }, "linux").join("\n")).toContain("🬺🬏") - expect(tui({ SSH_TTY: "/dev/pts/0" }, "linux").join("\n")).not.toContain("🬺🬏") - expect(plain({ SSH_TTY: "/dev/pts/0" }, "linux").join("\n")).not.toContain("🬁🬬") + expect(tui({}, "win32").join("\n")).not.toContain("🬺🬏") + expect(plain({}, "win32").join("\n")).not.toContain("🬁🬬") }) test("formats child session exit logo", () => { - const out = session("Title", "ses_test", "", "", { SSH_TTY: "/dev/pts/0" }, "linux") + const out = session("Title", "ses_test", "", "", {}, "win32") expect(out).toContain("Title") expect(out).not.toContain("🬺🬏") }) diff --git a/packages/opencode/test/kilocode/oauth-branding.test.ts b/packages/opencode/test/kilocode/oauth-branding.test.ts new file mode 100644 index 00000000000..1a8556a5b5c --- /dev/null +++ b/packages/opencode/test/kilocode/oauth-branding.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +const root = path.join(__dirname, "..", "..") + +describe("Kilo OAuth branding", () => { + test("Codex OAuth browser flow uses Kilo branding", async () => { + const src = await Bun.file(path.join(root, "src", "plugin", "codex.ts")).text() + + expect(src).toContain('originator: "kilo"') + expect(src).toContain("return to Kilo") + expect(src).not.toContain('originator: "opencode"') + expect(src).not.toContain("return to OpenCode") + }) + + test("MCP OAuth callback page uses Kilo branding", async () => { + const src = await Bun.file(path.join(root, "src", "mcp", "oauth-callback.ts")).text() + + expect(src).toContain("return to Kilo") + expect(src).not.toContain("return to OpenCode") + }) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts b/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts index 763a9db39b7..9dfa4b8e2ab 100644 --- a/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { OpenApi } from "effect/unstable/httpapi" +import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process" import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental" import { PublicApi } from "../../../src/server/routes/instance/httpapi/public" @@ -79,6 +80,12 @@ describe("Kilo HttpApi bridge", () => { const hono = new Set(openApiRouteKeys(await Server.openapiHono())) const effect = new Set(openApiRouteKeys(effectOpenApi())) const kilo = [ + `GET ${BackgroundProcessPaths.list}`, + "GET /background-process/{processID}", + "GET /background-process/{processID}/logs", + "POST /background-process/{processID}/stop", + "POST /background-process/{processID}/restart", + "POST /background-process/session/{sessionID}/stop", "POST /permission/allow-everything", "POST /enhance-prompt", "POST /commit-message", @@ -111,6 +118,8 @@ describe("Kilo HttpApi bridge", () => { expect(kilo.filter((route) => !hono.has(route))).toEqual([]) expect(kilo.filter((route) => !effect.has(route))).toEqual([]) + expect(hono.has("POST /background-process")).toBe(false) + expect(effect.has("POST /background-process")).toBe(false) expect(effect.has("GET /indexing/status")).toBe(true) }) diff --git a/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts b/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts index 1a235c7b134..fc19ce12595 100644 --- a/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts +++ b/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts @@ -81,3 +81,45 @@ describe("KiloSessionProcessor.extractReviewTelemetry", () => { expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined() }) }) + +describe("KiloSessionProcessor.suggestionReviewTelemetry", () => { + test("returns suggest-sourced telemetry for accepted review commands", () => { + expect( + KiloSessionProcessor.suggestionReviewTelemetry({ + accepted: { prompt: "/local-review-uncommitted --focus telemetry" }, + }), + ).toEqual({ ...expected("local-review-uncommitted"), tool: "suggest" }) + }) + + test("returns undefined for accepted non-review commands", () => { + expect(KiloSessionProcessor.suggestionReviewTelemetry({ accepted: { prompt: "/test" } })).toBeUndefined() + }) + + test("returns undefined when accepted prompt is not a slash command", () => { + expect(KiloSessionProcessor.suggestionReviewTelemetry({ accepted: { prompt: "Run tests" } })).toBeUndefined() + }) + + test("returns undefined when accepted metadata is missing", () => { + expect(KiloSessionProcessor.suggestionReviewTelemetry({ dismissed: true })).toBeUndefined() + }) +}) + +describe("KiloSessionProcessor.extractSuggestionReviewTelemetry", () => { + test("recovers review telemetry from completed suggest tool metadata", () => { + const parts = [ + { + type: "tool", + tool: "suggest", + state: { + status: "completed", + metadata: { accepted: { prompt: "/local-review" } }, + }, + }, + ] + + expect(KiloSessionProcessor.extractSuggestionReviewTelemetry(parts as unknown as MessageV2.Part[])).toEqual({ + ...expected("local-review"), + tool: "suggest", + }) + }) +}) diff --git a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts index 53f635c8b10..c1c40bba887 100644 --- a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts +++ b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { Telemetry } from "@kilocode/kilo-telemetry" import { WithInstance } from "../../../src/project/with-instance" import { Suggestion } from "../../../src/kilocode/suggestion" import { tmpdir } from "../../fixture/fixture" +afterEach(() => { + mock.restore() +}) + describe("suggestion", () => { test("show adds pending request with blocking flag", async () => { await using tmp = await tmpdir({ git: true }) @@ -54,6 +59,178 @@ describe("suggestion", () => { }) }) + test("accept tracks suggestion telemetry with parsed slash command", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const track = spyOn(Telemetry, "trackSuggestionAccepted") + const ask = Suggestion.show({ + sessionID: "ses_test", + text: "Review changes?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted --focus tests" }], + }) + + const list = await Suggestion.list() + await Suggestion.accept({ requestID: list[0]!.id, index: 0 }) + + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith({ + sessionId: "ses_test", + requestId: list[0]!.id, + index: 0, + tool: "suggest", + command: "local-review-uncommitted", + actionCount: 1, + }) + await expect(ask).resolves.toEqual({ label: "Review", prompt: "/local-review-uncommitted --focus tests" }) + }, + }) + }) + + test("show tracks review suggestion telemetry with parsed slash command", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const track = spyOn(Telemetry, "trackSuggestionShown") + const ask = Suggestion.show({ + sessionID: "ses_test", + text: "Review changes?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted --focus tests" }], + }) + + const list = await Suggestion.list() + + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith({ + sessionId: "ses_test", + requestId: list[0]!.id, + index: 0, + tool: "suggest", + command: "local-review-uncommitted", + actionCount: 1, + }) + + await Suggestion.dismiss(list[0]!.id) + await expect(ask).rejects.toBeInstanceOf(Suggestion.DismissedError) + }, + }) + }) + + test("show and accept parse local review arguments as local-review", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const shown = spyOn(Telemetry, "trackSuggestionShown") + const accepted = spyOn(Telemetry, "trackSuggestionAccepted") + const ask = Suggestion.show({ + sessionID: "ses_test", + text: "Review release?", + actions: [ + { label: "Review", prompt: "/local-review release -- focus on tests" }, + { label: "Skip", prompt: "Skip this review." }, + ], + }) + + const list = await Suggestion.list() + + expect(shown).toHaveBeenCalledTimes(1) + expect(shown).toHaveBeenCalledWith({ + sessionId: "ses_test", + requestId: list[0]!.id, + index: 0, + tool: "suggest", + command: "local-review", + actionCount: 2, + }) + + await Suggestion.accept({ requestID: list[0]!.id, index: 0 }) + + expect(accepted).toHaveBeenCalledTimes(1) + expect(accepted).toHaveBeenCalledWith({ + sessionId: "ses_test", + requestId: list[0]!.id, + index: 0, + tool: "suggest", + command: "local-review", + actionCount: 2, + }) + await expect(ask).resolves.toEqual({ label: "Review", prompt: "/local-review release -- focus on tests" }) + }, + }) + }) + + test("non-review commands do not track suggestion telemetry", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const shown = spyOn(Telemetry, "trackSuggestionShown") + const accepted = spyOn(Telemetry, "trackSuggestionAccepted") + const ask = Suggestion.show({ + sessionID: "ses_test", + text: "Run tests?", + actions: [{ label: "Test", prompt: "/custom-project-command" }], + }) + + const list = await Suggestion.list() + await Suggestion.accept({ requestID: list[0]!.id, index: 0 }) + + expect(shown).toHaveBeenCalledTimes(0) + expect(accepted).toHaveBeenCalledTimes(0) + await expect(ask).resolves.toEqual({ label: "Test", prompt: "/custom-project-command" }) + }, + }) + }) + + test("dismiss does not track accepted suggestion telemetry", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const shown = spyOn(Telemetry, "trackSuggestionShown") + const accepted = spyOn(Telemetry, "trackSuggestionAccepted") + const ask = Suggestion.show({ + sessionID: "ses_test", + text: "Review changes?", + actions: [{ label: "Review", prompt: "/local-review" }], + }) + + const list = await Suggestion.list() + await Suggestion.dismiss(list[0]!.id) + + expect(shown).toHaveBeenCalledTimes(1) + expect(accepted).toHaveBeenCalledTimes(0) + await expect(ask).rejects.toBeInstanceOf(Suggestion.DismissedError) + }, + }) + }) + + test("invalid action index does not track accepted suggestion telemetry", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const shown = spyOn(Telemetry, "trackSuggestionShown") + const accepted = spyOn(Telemetry, "trackSuggestionAccepted") + const ask = Suggestion.show({ + sessionID: "ses_test", + text: "Review changes?", + actions: [{ label: "Review", prompt: "/local-review" }], + }) + + const list = await Suggestion.list() + await expect(Suggestion.accept({ requestID: list[0]!.id, index: 1 })).resolves.toBe(false) + + expect(shown).toHaveBeenCalledTimes(1) + expect(accepted).toHaveBeenCalledTimes(0) + await expect(ask).rejects.toThrow("Invalid action index: 1") + }, + }) + }) + test("dismiss rejects pending request and removes it", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index fc92a9b884b..6b034ced935 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -65,7 +65,7 @@ const seed = Effect.fn("NestedTaskToolTest.seed")(function* () { function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { diff --git a/packages/opencode/test/kilocode/terminal.test.ts b/packages/opencode/test/kilocode/terminal.test.ts new file mode 100644 index 00000000000..50d5d337777 --- /dev/null +++ b/packages/opencode/test/kilocode/terminal.test.ts @@ -0,0 +1,94 @@ +import { afterEach, expect, test } from "bun:test" +import { kitty, sequences } from "../../src/kilocode/cli/cmd/tui/util/terminal" + +const keys = ["TERM_PROGRAM", "MSYSTEM", "KILO_DISABLE_KITTY_KEYBOARD", "KILO_ENABLE_KITTY_KEYBOARD"] as const +type Key = (typeof keys)[number] +const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) as Record + +function env(input: Partial>) { + for (const key of keys) { + const value = input[key] + if (value === undefined) { + delete process.env[key] + continue + } + process.env[key] = value + } +} + +function restore() { + for (const key of keys) { + const value = saved[key] + if (value === undefined) { + delete process.env[key] + continue + } + process.env[key] = value + } +} + +afterEach(() => { + restore() +}) + +test("enables Kitty keyboard reset by default", () => { + env({}) + + expect(kitty()).toBe(true) + expect(sequences()).toContain("\x1b[ { + env({ TERM_PROGRAM: "mintty" }) + + expect(kitty()).toBe(false) + expect(sequences()).not.toContain("\x1b[ { + env({ MSYSTEM: "MINGW64" }) + + expect(kitty()).toBe(false) + expect(sequences()).not.toContain("\x1b[ { + env({ KILO_ENABLE_KITTY_KEYBOARD: "1", MSYSTEM: "MINGW64" }) + + expect(kitty()).toBe(true) + expect(sequences()).toContain("\x1b[ { + env({ KILO_DISABLE_KITTY_KEYBOARD: "1", KILO_ENABLE_KITTY_KEYBOARD: "1" }) + + expect(kitty()).toBe(false) + expect(sequences()).not.toContain("\x1b[ { + env({ KILO_DISABLE_KITTY_KEYBOARD: "1" }) + + expect(sequences()).toEqual( + expect.arrayContaining([ + "\x1b[?9l", + "\x1b[?1000l", + "\x1b[?1001l", + "\x1b[?1002l", + "\x1b[?1003l", + "\x1b[?1005l", + "\x1b[?1006l", + "\x1b[?1007l", + "\x1b[?1015l", + "\x1b[?1016l", + "\x1b[?2004l", + "\x1b[?1004l", + "\x1b[?1l", + "\x1b>", + "\x1b[?66l", + "\x1b[>4;0m", + "\x1b[?25h", + "\x1b[0m", + ]), + ) +}) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 1958b6e8925..598b46adb6a 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -189,24 +189,35 @@ describe("kilocode tool registry indexing", () => { semantic: def("semantic_search"), recall: def("recall"), manager: def("agent_manager"), + process: def("background_process"), } try { process.env["KILO_CLIENT"] = "cli" - expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"]) + expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual([ + "semantic_search", + "recall", + "background_process", + ]) expect( KiloToolRegistry.extra(tools, { experimental: { codebase_search: true, agent_manager_tool: true } }).map( (tool) => tool.id, ), - ).toEqual(["codebase_search", "semantic_search", "recall"]) + ).toEqual(["codebase_search", "semantic_search", "recall", "background_process"]) process.env["KILO_CLIENT"] = "vscode" expect( KiloToolRegistry.extra(tools, { experimental: { codebase_search: true, agent_manager_tool: true } }).map( (tool) => tool.id, ), - ).toEqual(["codebase_search", "semantic_search", "recall", "agent_manager"]) - expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual(["recall"]) + ).toEqual(["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"]) + expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([ + "recall", + "background_process", + ]) + + process.env["KILO_CLIENT"] = "desktop" + expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"]) } finally { if (prev === undefined) delete process.env["KILO_CLIENT"] if (prev !== undefined) process.env["KILO_CLIENT"] = prev diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 2699f1e516b..f87415aae97 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -91,7 +91,7 @@ const seed = Effect.fn("TaskToolModelTest.seed")(function* (title = "Parent") { function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 300ffad36c5..503400c685e 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -12,8 +12,14 @@ process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1" const { Flag } = await import("@opencode-ai/core/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") +const { InstanceBootstrap } = await import("../../src/project/bootstrap") const { Instance } = await import("../../src/project/instance") -const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const { InstanceStore } = await import("../../src/project/instance-store") +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) const experimental = Flag.KILO_EXPERIMENTAL_WORKSPACES diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 53ff547ac14..82eacfb6df8 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { parsePatch } from "diff" import { Effect } from "effect" import fs from "fs/promises" import path from "path" @@ -288,6 +289,28 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps carriage returns inside patch hunks", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, 20_000) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts new file mode 100644 index 00000000000..0c692c50c85 --- /dev/null +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -0,0 +1,131 @@ +// End-to-end regression test for opencode#24432. +// +// Routes through the actual ai-gateway-provider + @ai-sdk/openai-compatible +// chain that provider.ts:811 builds at runtime, with only the network boundary +// stubbed. Asserts that `reasoning_effort` (and other provider options the +// transform emits) actually land in the body Cloudflare AI Gateway forwards +// upstream, which is the only place the bug was observable. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import type { JSONValue } from "ai" +import { generateText } from "ai" +import { createAiGateway } from "ai-gateway-provider" +import { createUnified } from "ai-gateway-provider/providers/unified" +import { ProviderTransform } from "@/provider/transform" +import type * as Provider from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" + +type Captured = { url: string; outerBody: unknown } +type ProviderOptions = Record> + +const realFetch = globalThis.fetch +let captured: Captured | null = null + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +beforeEach(() => { + captured = null + const handle = async (input: Parameters[0], init?: Parameters[1]): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url.startsWith("https://gateway.ai.cloudflare.com/")) { + const bodyText = typeof init?.body === "string" ? init.body : "" + captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null } + return new Response( + JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion", + created: 0, + model: "openai/gpt-5.4", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + return realFetch(input, init) + } + // `typeof fetch` includes Bun's `preconnect` method; preserve it from realFetch. + const stubFetch: typeof fetch = Object.assign(handle, { preconnect: realFetch.preconnect.bind(realFetch) }) + globalThis.fetch = stubFetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({ + id: ModelID.make(`cloudflare-ai-gateway/${apiId}`), + providerID: ProviderID.make("cloudflare-ai-gateway"), + name: apiId, + api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" }, + capabilities: { + reasoning: true, + temperature: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 1_000_000, output: 128_000 }, + status: "active", + options: {}, + headers: {}, + release_date: releaseDate, +}) + +// ai-gateway-provider sends an array of step descriptors; each entry's `query` +// is the body forwarded to the upstream provider. +function extractUpstreamQuery(body: unknown): Record | undefined { + if (!Array.isArray(body) || body.length === 0) return undefined + const first = body[0] + if (!isRecord(first)) return undefined + const query = first.query + return isRecord(query) ? query : undefined +} + +async function callThroughGateway(apiId: string, providerOptions: ProviderOptions) { + const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" }) + const unified = createUnified() + await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions }) + return extractUpstreamQuery(captured?.outerBody) +} + +describe("cf-ai-gateway end-to-end (regression: #24432)", () => { + test("ProviderTransform.providerOptions output puts reasoning_effort on the wire", async () => { + // The full chain the runtime exercises: + // transform.providerOptions() -> openaiCompatible key + // -> @ai-sdk/openai-compatible reads it as compatibleOptions + // -> emits body.reasoning_effort + // -> ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com + const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" }) + expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "xhigh" } }) + + const upstream = await callThroughGateway("openai/gpt-5.4", opts) + expect(upstream?.reasoning_effort).toBe("xhigh") + }) + + test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => { + // The other half of the bug: workflow `variant: xhigh` flows through variants() + // and must reach the wire. variants() returns the providerOptions payload + // unwrapped; providerOptions() wraps it under the SDK key. + const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4")) + expect(variants.xhigh).toEqual({ reasoningEffort: "xhigh" }) + + const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh) + const upstream = await callThroughGateway("openai/gpt-5.4", opts) + expect(upstream?.reasoning_effort).toBe("xhigh") + }) + + test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => { + // Sanity: confirms the bug class. If a future change accidentally restores + // providerID-keyed providerOptions, this test fails before users notice. + const upstream = await callThroughGateway("openai/gpt-5.4", { + "cloudflare-ai-gateway": { reasoningEffort: "high" }, + }) + expect(upstream?.reasoning_effort).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4fb83641eba..c8bee143454 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1123,6 +1123,118 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { }) }) +describe("ProviderTransform.message - surrogate sanitization", () => { + const model = { + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + } as any + + test("replaces lone surrogates in model-visible text", () => { + const lone = "\uD83D" + const valid = "🚀" + const sanitized = "�" + const text = (label: string) => `${label} ${lone} and ${valid}` + const expected = (label: string) => `${label} ${sanitized} and ${valid}` + const msgs = [ + { role: "system", content: text("system") }, + { role: "user", content: text("user string") }, + { + role: "user", + content: [ + { type: "text", text: text("user text") }, + { type: "image", image: "data:image/png;base64,abcd" }, + ], + }, + { role: "assistant", content: text("assistant string") }, + { + role: "assistant", + content: [ + { type: "text", text: text("assistant text") }, + { type: "reasoning", text: text("assistant reasoning") }, + { type: "tool-call", toolCallId: "call-1", toolName: "Read", input: { filePath: ".opencode/tool/emoji.ts" } }, + { + type: "tool-result", + toolCallId: "call-2", + toolName: "Read", + output: { type: "text", value: text("assistant tool text") }, + }, + { + type: "tool-result", + toolCallId: "call-3", + toolName: "Read", + output: { type: "error-text", value: text("assistant tool error") }, + }, + { + type: "tool-result", + toolCallId: "call-4", + toolName: "Read", + output: { type: "content", value: [{ type: "text", text: text("assistant tool content") }] }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-5", + toolName: "Read", + output: { type: "text", value: text("tool text") }, + }, + { + type: "tool-result", + toolCallId: "call-6", + toolName: "Read", + output: { type: "error-text", value: text("tool error") }, + }, + { + type: "tool-result", + toolCallId: "call-7", + toolName: "Read", + output: { type: "content", value: [{ type: "text", text: text("tool content") }] }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].content).toBe(expected("system")) + expect(result[1].content).toBe(expected("user string")) + expect(result[2].content[0].text).toBe(expected("user text")) + expect(result[3].content).toBe(expected("assistant string")) + expect(result[4].content[0].text).toBe(expected("assistant text")) + expect(result[4].content[1].text).toBe(expected("assistant reasoning")) + expect(result[4].content[3].output.value).toBe(expected("assistant tool text")) + expect(result[4].content[4].output.value).toBe(expected("assistant tool error")) + expect(result[4].content[5].output.value[0].text).toBe(expected("assistant tool content")) + expect(result[5].content[0].output.value).toBe(expected("tool text")) + expect(result[5].content[1].output.value).toBe(expected("tool error")) + expect(result[5].content[2].output.value[0].text).toBe(expected("tool content")) + expect(result[2].content[1]).toEqual({ type: "image", image: "data:image/png;base64,abcd" }) + }) +}) + describe("ProviderTransform.message - empty image handling", () => { const mockModel = { id: "anthropic/claude-3-5-sonnet", @@ -1993,7 +2105,7 @@ describe("ProviderTransform.message - bedrock caching with non-bedrock providerI const msgs = [ { role: "system", - content: [{ type: "text", text: "You are a helpful assistant" }], + content: "You are a helpful assistant", }, { role: "user", @@ -2007,7 +2119,7 @@ describe("ProviderTransform.message - bedrock caching with non-bedrock providerI expect(result[0].providerOptions?.bedrock).toEqual({ cachePoint: { type: "default" }, }) - expect(result[0].content[0].providerOptions?.bedrock).toBeUndefined() + expect(result[0].content).toBe("You are a helpful assistant") }) }) @@ -2044,7 +2156,7 @@ describe("ProviderTransform.message - cache control on gateway", () => { const msgs = [ { role: "system", - content: [{ type: "text", text: "You are a helpful assistant" }], + content: "You are a helpful assistant", }, { role: "user", @@ -2054,7 +2166,7 @@ describe("ProviderTransform.message - cache control on gateway", () => { const result = ProviderTransform.message(msgs, model, {}) as any[] - expect(result[0].content[0].providerOptions).toBeUndefined() + expect(result[0].content).toBe("You are a helpful assistant") expect(result[0].providerOptions).toBeUndefined() }) @@ -3077,6 +3189,36 @@ describe("ProviderTransform.variants", () => { const result = ProviderTransform.variants(model) expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) }) + + test("dotted gpt-5.x ids include 'minimal' (regression: matcher used to miss gpt-5.4)", () => { + const model = createMockModel({ + id: "gpt-5.4", + providerID: "openai", + api: { + id: "gpt-5.4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2026-03-05", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + }) + + test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => { + const model = createMockModel({ + id: "gpt-50", + providerID: "openai", + api: { + id: "gpt-50", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2024-01-01", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) }) describe("@ai-sdk/anthropic", () => { @@ -3609,8 +3751,87 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) }) + // kilocode_change end + + describe("ai-gateway-provider (cloudflare-ai-gateway)", () => { + const cfModel = (apiId: string, releaseDate = "2024-01-01") => + createMockModel({ + id: `cloudflare-ai-gateway/${apiId}`, + providerID: "cloudflare-ai-gateway", + api: { + id: apiId, + url: "https://gateway.ai.cloudflare.com/v1/compat", + npm: "ai-gateway-provider", + }, + release_date: releaseDate, + }) + + test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => { + const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05")) + expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + expect(Object.keys(result)).toContain("minimal") + }) + + test("openai gpt-5.2-codex includes xhigh", () => { + const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11")) + expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + }) + + test("openai gpt-4o (no reasoning) returns empty", () => { + const model = cfModel("openai/gpt-4o") + model.capabilities.reasoning = false + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("non-openai upstream falls back to widely-supported OAI efforts", () => { + const result = ProviderTransform.variants(cfModel("anthropic/claude-sonnet-4-6")) + expect(result).toEqual({ + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + }) + }) + }) +}) + +describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { + const createModel = (overrides: Partial = {}) => + ({ + id: "cloudflare-ai-gateway/openai/gpt-5.4", + providerID: "cloudflare-ai-gateway", + api: { + id: "openai/gpt-5.4", + url: "https://gateway.ai.cloudflare.com/v1/compat", + npm: "ai-gateway-provider", + }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 1_000_000, output: 128_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-03-05", + ...overrides, + }) as any + + test("routes options under openaiCompatible (the key @ai-sdk/openai-compatible reads)", () => { + // Regression: previously fell back to providerID="cloudflare-ai-gateway", + // which @ai-sdk/openai-compatible never reads, silently dropping reasoningEffort. + const result = ProviderTransform.providerOptions(createModel(), { reasoningEffort: "high" }) + expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) + }) }) -// kilocode_change end // kilocode_change start - tests for reasoningSummary guard describe("ProviderTransform.options - OpenAI Responses API params guard", () => { diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts index fc693ee76a8..fe54b54677f 100644 --- a/packages/opencode/test/server/auth.test.ts +++ b/packages/opencode/test/server/auth.test.ts @@ -22,7 +22,8 @@ describe("ServerAuth", () => { expect(ServerAuth.headers()).toBeUndefined() }) - test("defaults to the kilo username", () => { // kilocode_change + test("defaults to the kilo username", () => { + // kilocode_change Flag.KILO_SERVER_PASSWORD = "secret" Flag.KILO_SERVER_USERNAME = undefined diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index d17dd14c87e..db95492802a 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -63,6 +63,19 @@ describe("HttpApi CORS", () => { }), ) + it.live("adds CORS headers to legacy unauthorized responses", () => + Effect.gen(function* () { + const response = yield* Effect.promise(async () => + Server.Legacy().app.request("/global/config", { + headers: { origin: "https://app.opencode.ai" }, + }), + ) + + expect(response.status).toBe(401) + expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai") + }), + ) + it.live("uses custom CORS origins passed to the server", () => Effect.gen(function* () { const listener = yield* Effect.acquireRelease( diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 56dda4f2558..a823afb957a 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -13,8 +13,8 @@ import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" // kilocode_change start - avoid InstanceLayer.layer's lazy bootstrap import in this test graph. import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" // kilocode_change end +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -39,6 +39,11 @@ const testStateLayer = Layer.effectDiscard( }), ) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + const it = testEffect( Layer.mergeAll( testStateLayer, @@ -48,7 +53,7 @@ const it = testEffect( InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)), // kilocode_change end Project.defaultLayer, - Workspace.defaultLayer, + workspaceLayer, ), ) diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts index d4116564e03..4361a40be08 100644 --- a/packages/opencode/test/server/httpapi-parity.test.ts +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -105,23 +105,22 @@ describe("404 mapping for missing session", () => { }) // ────────────────────────────────────────────────────────────────────────────── -// Reproducer 3: 404 response body shape should match Hono's NamedError -// envelope `{ name, data: { message } }`. HttpApi returns the typed-error -// shape `{ _tag }` instead. SDK consumers reading `error.data.message` -// see undefined. -// -// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// Reproducer 3: 404 response body shape should match Hono's public NamedError +// envelope `{ name, data: { message } }`. SDK consumers read +// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a +// compatibility break. // ────────────────────────────────────────────────────────────────────────────── describe("Error JSON shape parity", () => { - test.todo("HttpApi 404 body matches NamedError shape", async () => { + test("HttpApi 404 body matches Hono shape", async () => { await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const headers = { "x-kilo-directory": tmp.path } - const response = await app(true).request("/session/ses_does_not_exist", { - headers: { "x-kilo-directory": tmp.path }, - }) + const hono = await app(false).request("/session/ses_does_not_exist", { headers }) + const httpapi = await app(true).request("/session/ses_does_not_exist", { headers }) - expect(response.status).toBe(404) - const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(httpapi.status).toBe(hono.status) + const body = (await httpapi.json()) as { name?: string; data?: { message?: string } } + expect(body).toEqual(await hono.json()) expect(body.name).toBe("NotFoundError") expect(typeof body.data?.message).toBe("string") }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index b8deb9a7db8..de0e8ba4fa5 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -50,9 +50,9 @@ const effectIt = testEffect( ), ) -function app() { - Flag.KILO_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function serverUrl() { @@ -121,6 +121,18 @@ describe("pty HttpApi bridge", () => { expect(missing.status).toBe(404) }) + test("matches Hono missing PTY error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-kilo-directory": tmp.path } + const path = PtyPaths.get.replace(":ptyID", PtyID.ascending()) + + const hono = await app(false).request(path, { headers }) + const httpapi = await app().request(path, { headers }) + + expect(httpapi.status).toBe(hono.status) + expect(await httpapi.json()).toEqual(await hono.json()) + }) + test("returns 404 for missing PTY websocket before upgrade", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 301312d2d62..72e18c61ce9 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -4,6 +4,7 @@ import type * as Scope from "effect/Scope" import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createKiloClient } from "@kilocode/sdk/v2" +import { validateSession } from "../../src/cli/cmd/tui/validate-session" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" @@ -13,6 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Config } from "@/config/config" import { Session as SessionNs } from "@/session/session" +import { errorMessage } from "../../src/util/error" import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" @@ -64,20 +66,23 @@ function client( directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { - const serverApp = app(backend, input) - const fetch = Object.assign( - async (request: RequestInfo | URL, init?: RequestInit) => - await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), - { preconnect: globalThis.fetch.preconnect }, - ) satisfies typeof globalThis.fetch return createKiloClient({ baseUrl: "http://localhost", directory, headers: input?.headers, - fetch, + fetch: serverFetch(backend, input), }) } +function serverFetch(backend: Backend, input?: { password?: string; username?: string }) { + const serverApp = app(backend, input) + return Object.assign( + async (request: RequestInfo | URL, init?: RequestInit) => + await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -129,6 +134,16 @@ function capture(request: () => Promise) { ) } +function captureThrown(request: () => Promise) { + return call(async () => { + try { + await request() + } catch (error) { + return error + } + }) +} + function expectStatus(request: () => Promise<{ response: Response }>, status: number) { return call(request).pipe( Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))), @@ -355,6 +370,46 @@ describe("HttpApi SDK", () => { ), ) + parity("matches generated SDK missing session errors across backends", (backend) => + withStandardProject(backend, ({ sdk }) => + Effect.gen(function* () { + const sessionID = "ses_missing" + const expected = { + name: "NotFoundError", + data: { message: `Session not found: ${sessionID}` }, + } + const missing = yield* capture(() => sdk.session.get({ sessionID })) + const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + + expect(missing.error).toEqual(expected) + expect(thrown).toEqual(expected) + return { + status: missing.status, + error: missing.error, + thrown, + } + }), + ), + ) + + parity("formats missing session validation errors for -s", (backend) => + withStandardProject(backend, ({ directory }) => + Effect.gen(function* () { + const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" + const thrown = yield* captureThrown(() => + validateSession({ + url: "http://localhost", + directory, + sessionID, + fetch: serverFetch(backend), + }), + ) + expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) + return errorMessage(thrown) + }), + ), + ) + parity("matches generated SDK basic auth behavior across backends", (backend) => withStandardProject(backend, ({ directory }) => Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index a220b4d2202..020c623ae05 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,24 +1,26 @@ import { afterEach, describe, expect } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" -import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "../../src/v2/session-message" +import { Modelv2 } from "../../src/v2/model" import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" @@ -30,6 +32,10 @@ void Log.init({ print: false }) const original = Flag.KILO_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) function app(experimental = true) { Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental @@ -54,7 +60,7 @@ function createSession(directory: string, input?: Session.CreateInput) { ) } -function createTextMessage(directory: string, sessionID: SessionID, text: string) { +function createTextMessage(directory: string, sessionID: SessionIDType, text: string) { return Effect.promise( async () => await WithInstance.provide({ @@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri extra: null, projectID: input.projectID, }), - ).pipe(Effect.provide(Workspace.defaultLayer)) + ).pipe(Effect.provide(workspaceLayer)) }) function request(path: string, init?: RequestInit) { @@ -124,6 +130,10 @@ function json(response: Response) { }) } +function responseJson(response: Response) { + return Effect.promise(() => response.json()) +} + function requestJson(path: string, init?: RequestInit) { return request(path, init).pipe(Effect.flatMap(json)) } @@ -146,6 +156,47 @@ afterEach(async () => { }) describe("session HttpApi", () => { + it.live( + "returns declared not found errors for read routes", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-kilo-directory": tmp.path } + const missingSession = SessionID.descending() + const missingSessionBody = { + name: "NotFoundError", + data: { message: `Session not found: ${missingSession}` }, + } + + const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers }) + expect(get.status).toBe(404) + expect(yield* responseJson(get)).toEqual(missingSessionBody) + + const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers }) + expect(messages.status).toBe(404) + expect(yield* responseJson(messages)).toEqual(missingSessionBody) + + const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), { + headers, + method: "DELETE", + }) + expect(remove.status).toBe(404) + expect(yield* responseJson(remove)).toEqual(missingSessionBody) + + const session = yield* createSession(tmp.path, { title: "missing message" }) + const missingMessage = MessageID.ascending() + const message = yield* request( + pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }), + { headers }, + ) + expect(message.status).toBe(404) + expect(yield* responseJson(message)).toEqual({ + name: "NotFoundError", + data: { message: `Message not found: ${missingMessage}` }, + }) + }), + ), + ) + it.live( "serves read routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => @@ -214,7 +265,11 @@ describe("session HttpApi", () => { id: SessionMessage.ID.create(), type: "assistant", agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, time: { created: DateTime.makeUnsafe(1) }, content: [], }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index d98456e6a1a..aee430ad6ac 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -72,14 +72,27 @@ describe("tui HttpApi bridge", () => { properties: { text: "from publish" }, }) + const missingSessionID = SessionID.descending() const missing = await app().request(TuiPaths.selectSession, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ sessionID: SessionID.descending() }), + body: JSON.stringify({ sessionID: missingSessionID }), }) expect(missing.status).toBe(404) }) + test("matches Hono missing selected session error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ sessionID: SessionID.descending() }) + + const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body }) + const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body }) + + expect(httpapi.status).toBe(hono.status) + expect(await httpapi.json()).toEqual(await hono.json()) + }) + test("matches legacy unknown execute command behavior", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" } diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index fefce0f0291..92fe3afcad3 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { @@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard( }), ) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + const it = testEffect( Layer.mergeAll( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer, - Workspace.defaultLayer, + workspaceLayer, Socket.layerWebSocketConstructorGlobal, ), ) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 0563ddce679..a55c143dca5 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -14,6 +14,8 @@ import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { WorkspaceRef } from "../../src/effect/instance-ref" @@ -23,9 +25,11 @@ void Log.init({ print: false }) const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI -const it = testEffect( - Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), ) +const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { @@ -168,22 +172,19 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), + body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) - const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { + const warped = yield* request(WorkspacePaths.warp, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({ - total: expect.any(Number), + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), }) + expect(warped.status).toBe(204) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) @@ -212,7 +213,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -257,7 +257,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -272,7 +271,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + body: JSON.stringify({ type: "local-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -327,7 +326,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -394,7 +393,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-session-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const session = yield* Session.Service.use((svc) => svc.create()).pipe( diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 66f641a0dcb..3c30d7181b8 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1219,7 +1219,9 @@ describe("session.compaction.process", () => { expect(captured).not.toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered[0]?.info.id).toBe(keep.id) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered[1]?.info.role).toBe("assistant") + expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) } finally { await rt.dispose() diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index a7853be0b8b..999b61b48e5 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1098,6 +1098,108 @@ describe("session.message-v2.toModelMessage", () => { }, ]) }) + + test("substitutes space for empty text between signed reasoning blocks", async () => { + // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)] + const assistantID = "m-assistant" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "reasoning", + text: "thinking-one", + metadata: { anthropic: { signature: "sig1" } }, + }, + { ...basePart(assistantID, "p3"), type: "text", text: "" }, + { ...basePart(assistantID, "p4"), type: "step-start" }, + { + ...basePart(assistantID, "p5"), + type: "reasoning", + text: "thinking-two", + metadata: { anthropic: { signature: "sig2" } }, + }, + { ...basePart(assistantID, "p6"), type: "text", text: "the answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later + expect(result).toHaveLength(2) + expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ") + expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") + }) + + test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { + // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + const assistantID = "m-assistant-bedrock" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "thinking-bedrock", + metadata: { bedrock: { signature: "bedrock-sig" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + }) + + test("leaves empty text alone when reasoning has no Anthropic signature", async () => { + // Non-Anthropic providers' reasoning doesn't position-validate, so empty text + // should be filtered normally rather than substituted. + const assistantID = "m-assistant-unsigned" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) + }) + + test("leaves empty text alone in assistant messages without reasoning", async () => { + const assistantID = "m-assistant-no-reasoning" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "text", text: "" }, + { ...basePart(assistantID, "p2"), type: "text", text: "hello" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"]) + }) }) describe("session.message-v2.fromError", () => { diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 35b67f7a071..05ec2bad494 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -834,7 +834,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) await svc.remove(session.id) }, @@ -889,7 +889,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) const forked = await svc.fork({ sessionID: session.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -964,7 +964,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) await svc.remove(session.id) }, @@ -1041,7 +1041,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) await svc.remove(session.id) }, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8af306ef258..fd055871774 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -31,6 +31,7 @@ import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" +import { Suggestion } from "../../src/kilocode/suggestion" // kilocode_change - accept suggestion in telemetry test import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionV2 } from "../../src/v2/session" @@ -946,6 +947,43 @@ it.live( ) // kilocode_change end +it.live( + "cancel propagates from slash command subtask to child session", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined + expect(typeof sessionID).toBe("string") + if (typeof sessionID !== "string") throw new Error("missing child session id") + const childID = SessionID.make(sessionID) + expect((yield* status.get(childID)).type).toBe("busy") + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + expect((yield* status.get(chat.id)).type).toBe("idle") + expect((yield* status.get(childID)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "cancel with queued callers resolves all cleanly", () => @@ -2106,6 +2144,59 @@ it.live( ), 30_000, ) + +it.live( + "accepted suggest tool marks following completion with review telemetry", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const trackSpy = spyOn(Telemetry, "trackLlmCompletion") + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Suggest telemetry", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.tool("suggest", { + suggest: "Run a local review?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted --focus telemetry" }], + }) + yield* llm.text("review done", { usage: { input: 100, output: 50 } }) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "Suggest a review action." }], + }) + .pipe(Effect.forkChild) + const request = yield* waitFor( + "suggestion request", + Effect.promise(() => Suggestion.list()).pipe( + Effect.map((items) => items.find((item) => item.sessionID === chat.id)), + ), + ) + + yield* Effect.promise(() => Suggestion.accept({ requestID: request.id, index: 0 })) + yield* Fiber.join(fiber) + + const tagged = trackSpy.mock.calls + .map((args) => args[0] as Parameters[0]) + .find( + (p) => + p.mode === "review" && + p.feature === "code_reviews" && + p.command === "local-review-uncommitted" && + p.tool === "suggest", + ) + expect(tagged).toBeDefined() + }), + { git: true, config: providerCfg }, + ), + 30_000, +) // kilocode_change end // Agent / command resolution errors diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 12946d12274..aa26fab8a6a 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" -import { EventTable } from "../../src/sync/event.sql" +import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" @@ -252,5 +252,76 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claims unowned event sequence on replay with ownerID", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "owned" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) + + it.live( + "ignores replay from a different owner after sequence is claimed", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + yield* SyncEvent.use.replay( + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "ignored" }, + }, + { publish: false, ownerID: "owner-2" }, + ) + + const events = Database.use((db) => db.select().from(EventTable).all()) + const sequence = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(events).toHaveLength(1) + expect(events[0].id).toBe("evt_1") + expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) }) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 3646c1b1851..8bd5ac390b9 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Instance } from "../../src/project/instance" import { Session } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" @@ -35,6 +34,14 @@ const it = testEffect( ), ) +function defer() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const session = yield* Session.Service const chat = yield* session.create({ title }) @@ -72,7 +79,7 @@ function stubOps(opts?: { childCost?: number }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.gen(function* () { @@ -118,102 +125,270 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa } describe("tool.task", () => { - it.live("description sorts subagents by name and is stable across calls", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const get = Effect.fnUntraced(function* () { - const tools = yield* registry.tools({ ...ref, agent: build }) - return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" - }) - const first = yield* get() - const second = yield* get() + it.instance( + "description sorts subagents by name and is stable across calls", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() - expect(first).toBe(second) + expect(first).toBe(second) - const alpha = first.indexOf("- alpha: Alpha agent") - const explore = first.indexOf("- explore:") - const general = first.indexOf("- general:") - const zebra = first.indexOf("- zebra: Zebra agent") + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) - }), - { - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("description hides denied subagents for the caller", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" + it.instance( + "description hides denied subagents for the caller", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" - expect(description).toContain("- alpha: Alpha agent") - expect(description).not.toContain("- zebra: Zebra agent") - }), - { - config: { - permission: { - task: { - "*": "allow", - zebra: "deny", - }, + expect(description).toContain("- alpha: Alpha agent") + expect(description).not.toContain("- zebra: Zebra agent") + }), + { + config: { + permission: { + task: { + "*": "allow", + zebra: "deny", }, - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + }, + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("execute resumes an existing task session from task_id", () => - provideTmpdirInstance(() => + it.instance("execute resumes an existing task session from task_id", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(child.id) + expect(result.metadata.sessionId).toBe(child.id) + expect(result.output).toContain(`task_id: ${child.id}`) + expect(seen?.sessionID).toBe(child.id) + }), + ) + + it.instance("execute asks by default and skips checks when bypassed", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const exec = (extra?: Record) => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + yield* exec() + yield* exec({ bypassAgentCheck: true }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + ) + + it.instance("execute cancels child session when abort signal fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ready = defer() + const cancelled = defer() + const abort = new AbortController() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.promise(() => { + ready.resolve(input) + return cancelled.promise + }).pipe(Effect.as(reply(input, "cancelled"))), + } + + const fiber = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.forkChild) + + const input = yield* Effect.promise(() => ready.promise) + abort.abort() + expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.instance("execute creates a child when task_id does not exist", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: "ses_missing", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(result.metadata.sessionId).not.toBe("ses_missing") + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + }), + ) + + it.instance( + "execute shapes child permissions for task, todowrite, and primary tools", + () => Effect.gen(function* () { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() - const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", - subagent_type: "general", - task_id: child.id, + subagent_type: "reviewer", }, { sessionID: chat.id, @@ -227,184 +402,57 @@ describe("tool.task", () => { }, ) - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(child.id) - expect(result.metadata.sessionId).toBe(child.id) - expect(result.output).toContain(`task_id: ${child.id}`) - expect(seen?.sessionID).toBe(child.id) - }), - ), - ) - - it.live("execute asks by default and skips checks when bypassed", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - const calls: unknown[] = [] - const promptOps = stubOps() - - const exec = (extra?: Record) => - def.execute( + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.parentID).toBe(chat.id) + // kilocode_change start — use arrayContaining: Kilo appends inherited caller restrictions + expect(child.permission).toEqual( + expect.arrayContaining([ { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", + permission: "todowrite", + pattern: "*", + action: "deny", }, { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps, ...extra }, - messages: [], - metadata: () => Effect.void, - ask: (input) => - Effect.sync(() => { - calls.push(input) - }), + permission: "bash", + pattern: "*", + action: "allow", }, - ) - - yield* exec() - yield* exec({ bypassAgentCheck: true }) - - expect(calls).toHaveLength(1) - expect(calls[0]).toEqual({ - permission: "task", - patterns: ["general"], - always: ["*"], - metadata: { - description: "inspect bug", - subagent_type: "general", - }, + { + permission: "read", + pattern: "*", + action: "allow", + }, + { + permission: "task", + pattern: "*", + action: "deny", + }, + ]), + ) + // kilocode_change end + expect(seen?.tools).toEqual({ + todowrite: false, + task: false, // kilocode_change - Kilo disallows nested subagents + bash: false, + read: false, }) }), - ), - ) - - it.live("execute creates a child when task_id does not exist", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - task_id: "ses_missing", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(result.metadata.sessionId) - expect(result.metadata.sessionId).not.toBe("ses_missing") - expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) - expect(seen?.sessionID).toBe(result.metadata.sessionId) - }), - ), - ) - - it.live("execute shapes child permissions for task, todowrite, and primary tools", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "reviewer", + { + config: { + agent: { + reviewer: { + mode: "subagent", + permission: { + task: "allow", }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const child = yield* sessions.get(result.metadata.sessionId) - expect(child.parentID).toBe(chat.id) - // kilocode_change start — use arrayContaining: Kilo appends inherited caller restrictions - expect(child.permission).toEqual( - expect.arrayContaining([ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "bash", - pattern: "*", - action: "allow", - }, - { - permission: "read", - pattern: "*", - action: "allow", - }, - { - permission: "task", - pattern: "*", - action: "deny", - }, - ]), - ) - // kilocode_change end - expect(seen?.tools).toEqual({ - todowrite: false, - task: false, // kilocode_change - Kilo disallows nested subagents - bash: false, - read: false, - }) - }), - { - config: { - agent: { - reviewer: { - mode: "subagent", - permission: { - task: "allow", - }, - }, - }, - experimental: { - primary_tools: ["bash", "read"], - openTelemetry: true, // kilocode_change }, }, + experimental: { + primary_tools: ["bash", "read"], + openTelemetry: true, // kilocode_change + }, }, - ), + }, ) }) @@ -566,7 +614,7 @@ describe("tool.task cost propagation", () => { const abort = new AbortController() // Stub that persists a partial cost, then aborts — mimics interrupted run after tokens billed. const ops: TaskPromptOps = { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.gen(function* () { diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 128177167cb..44ac031edab 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "../../src/v2/event" +import { Modelv2 } from "../../src/v2/model" import { SessionEvent } from "../../src/v2/session-event" import { SessionMessageUpdater } from "../../src/v2/session-message-updater" @@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, snapshot: "before", }, } satisfies SessionEvent.Event) @@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) @@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) diff --git a/packages/opencode/test/workspace/workspace-restore.test.ts b/packages/opencode/test/workspace/workspace-restore.test.ts deleted file mode 100644 index bbd0a1639f9..00000000000 --- a/packages/opencode/test/workspace/workspace-restore.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" -import fs from "node:fs/promises" -import path from "node:path" -import { GlobalBus } from "../../src/bus/global" -import { registerAdapter } from "../../src/control-plane/adapters" -import type { WorkspaceAdapter } from "../../src/control-plane/types" -import { Workspace } from "../../src/control-plane/workspace" -import { AppRuntime } from "../../src/effect/app-runtime" -import { Flag } from "@opencode-ai/core/flag/flag" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { Session as SessionNs } from "@/session/session" -import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { SyncEvent } from "../../src/sync" -import { EventTable } from "../../src/sync/event.sql" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = Flag.KILO_EXPERIMENTAL_WORKSPACES - -beforeEach(() => { - Database.close() - Flag.KILO_EXPERIMENTAL_WORKSPACES = true -}) - -afterEach(async () => { - mock.restore() - await disposeAllInstances() - Flag.KILO_EXPERIMENTAL_WORKSPACES = original - await resetDatabase() -}) - -function create(input?: SessionNs.CreateInput) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) -} - -function get(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) -} - -function updateMessage(msg: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) -} - -function updatePart(part: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) -} - -function createWorkspace(input: Workspace.CreateInput) { - return AppRuntime.runPromise(Workspace.Service.use((svc) => svc.create(input))) -} - -function sessionRestore(input: Workspace.SessionRestoreInput) { - return AppRuntime.runPromise(Workspace.Service.use((svc) => svc.sessionRestore(input))) -} - -async function user(sessionID: SessionID, text: string) { - const msg = await updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - await updatePart({ - id: PartID.ascending(), - sessionID, - messageID: msg.id, - type: "text", - text, - }) -} - -function remote(dir: string, url: string): WorkspaceAdapter { - return { - name: "remote", - description: "remote", - configure(info) { - return { - ...info, - directory: dir, - } - }, - async create() { - await fs.mkdir(dir, { recursive: true }) - }, - async remove() {}, - target() { - return { - type: "remote" as const, - url, - } - }, - } -} - -function local(dir: string): WorkspaceAdapter { - return { - name: "local", - description: "local", - configure(info) { - return { - ...info, - directory: dir, - } - }, - async create() { - await fs.mkdir(dir, { recursive: true }) - }, - async remove() {}, - target() { - return { - type: "local" as const, - directory: dir, - } - }, - } -} - -function eventStreamResponse() { - return new Response(new ReadableStream({ start() {} }), { - status: 200, - headers: { - "content-type": "text/event-stream", - }, - }) -} - -// kilocode_change - skip these tests after upstream's Workspace refactor. -// They rely on spyOn(globalThis, "fetch") and spyOn(SyncEvent, "replayAll") to -// intercept HTTP and replay paths. Upstream now routes those through the Effect -// FetchHttpClient layer (fiber-ref defaultValue, not the spied globalThis.fetch -// descriptor) and the SyncEvent.Service injected method (not the module-level -// SyncEvent.replayAll export). Restoring coverage requires rewriting the -// fixtures to inject Effect-side mock layers. Tracked for follow-up. -describe.skip("Workspace.sessionRestore", () => { - test("replays session events in batches of 10 and emits progress", async () => { - await using tmp = await tmpdir({ git: true }) - const dir = path.join(tmp.path, ".restore") - const seen: any[] = [] - const posts: Array<{ - path: string - body: { directory: string; events: Array<{ seq: number; aggregateID: string }> } - }> = [] - const on = (evt: any) => seen.push(evt) - GlobalBus.on("event", on) - - const raw = globalThis.fetch - spyOn(globalThis, "fetch").mockImplementation( - Object.assign( - async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { - const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) - if (url.pathname === "/base/global/event") { - return eventStreamResponse() - } - if (url.pathname === "/base/sync/history") { - return Response.json([]) - } - const body = JSON.parse(String(init?.body)) - posts.push({ - path: url.pathname, - body, - }) - return Response.json({ sessionID: body.events[0].aggregateID }) - }, - { - preconnect: raw.preconnect?.bind(raw), - }, - ) as typeof globalThis.fetch, - ) - - try { - const setup = await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - registerAdapter(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base")) - const space = await createWorkspace({ - type: "worktree", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - const session = await create({}) - for (let i = 0; i < 6; i++) { - await user(session.id, `msg ${i}`) - } - const rows = Database.use((db) => - db - .select({ seq: EventTable.seq }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, session.id)) - .orderBy(asc(EventTable.seq)) - .all(), - ) - const result = await sessionRestore({ - workspaceID: space.id, - sessionID: session.id, - }) - return { space, session, rows, result } - }, - }) - - expect(setup.rows).toHaveLength(13) - expect(setup.result).toEqual({ total: 2 }) - expect(posts).toHaveLength(2) - expect(posts[0]?.path).toBe("/base/sync/replay") - expect(posts[1]?.path).toBe("/base/sync/replay") - expect(posts[0]?.body.directory).toBe(dir) - expect(posts[1]?.body.directory).toBe(dir) - expect(posts[0]?.body.events).toHaveLength(10) - expect(posts[1]?.body.events).toHaveLength(4) - expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([ - ...setup.rows.map((row) => row.seq), - setup.rows.at(-1)!.seq + 1, - ]) - expect(posts[1]?.body.events.at(-1)).toMatchObject({ - aggregateID: setup.session.id, - seq: setup.rows.at(-1)!.seq + 1, - type: SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version), - data: { - sessionID: setup.session.id, - info: { - workspaceID: setup.space.id, - }, - }, - }) - - const restore = seen.filter( - (evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type, - ) - expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2]) - expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2]) - expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([ - setup.session.id, - setup.session.id, - setup.session.id, - ]) - } finally { - GlobalBus.off("event", on) - } - }) - - test("replays locally without posting to a server", async () => { - await using tmp = await tmpdir({ git: true }) - const dir = path.join(tmp.path, ".restore-local") - const seen: any[] = [] - const on = (evt: any) => seen.push(evt) - GlobalBus.on("event", on) - - const fetch = spyOn(globalThis, "fetch") - const replayAll = spyOn(SyncEvent, "replayAll") - - try { - const setup = await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - registerAdapter(Instance.project.id, "local-restore", local(dir)) - const space = await createWorkspace({ - type: "local-restore", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - const session = await create({}) - for (let i = 0; i < 6; i++) { - await user(session.id, `msg ${i}`) - } - const result = await sessionRestore({ - workspaceID: space.id, - sessionID: session.id, - }) - const updated = await get(session.id) - return { space, session, result, updated } - }, - }) - - expect(setup.result).toEqual({ total: 2 }) - expect(fetch).not.toHaveBeenCalled() - expect(replayAll).toHaveBeenCalledTimes(2) - expect(setup.updated.workspaceID).toBe(setup.space.id) - - const restore = seen.filter( - (evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type, - ) - expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2]) - } finally { - GlobalBus.off("event", on) - } - }) -}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0827f65dcd4..a04dfe7b987 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.3.7", + "version": "7.3.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index aea2929e7be..13cc9040556 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -12,6 +12,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, + BackgroundProcessInfo, TextPart, Config as SdkConfig, } from "@kilocode/sdk/v2" @@ -275,6 +276,7 @@ export type TuiState = { count: () => number diff: (sessionID: string) => ReadonlyArray todo: (sessionID: string) => ReadonlyArray + processes: (sessionID: string) => ReadonlyArray messages: (sessionID: string) => ReadonlyArray status: (sessionID: string) => SessionStatus | undefined permission: (sessionID: string) => ReadonlyArray @@ -312,6 +314,11 @@ export type TuiSidebarLspItem = Pick export type TuiSidebarTodoItem = Pick +export type TuiSidebarBackgroundProcessItem = Pick< + BackgroundProcessInfo, + "id" | "pid" | "command" | "cwd" | "description" | "ports" | "status" | "output" +> + export type TuiSidebarFileItem = { file: string additions: number diff --git a/packages/script/package.json b/packages/script/package.json index 0203ff63764..ca658734147 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.3.7", + "version": "7.3.8", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 52214068eb3..79106e186f8 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.3.7", + "version": "7.3.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 61e87d8f5e1..e8733856bf2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -13,6 +13,16 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + BackgroundProcessGetErrors, + BackgroundProcessGetResponses, + BackgroundProcessListResponses, + BackgroundProcessLogsErrors, + BackgroundProcessLogsResponses, + BackgroundProcessRestartErrors, + BackgroundProcessRestartResponses, + BackgroundProcessStopErrors, + BackgroundProcessStopResponses, + BackgroundProcessStopSessionResponses, CommandListResponses, CommitMessageGenerateErrors, CommitMessageGenerateResponses, @@ -40,9 +50,9 @@ import type { ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ExperimentalWorkspaceSessionRestoreResponses, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceWarpErrors, + ExperimentalWorkspaceWarpResponses, FileListResponses, FilePartInput, FilePartSource, @@ -183,6 +193,7 @@ import type { SessionDeleteResponses, SessionDelivery, SessionDiffResponses, + SessionForkErrors, SessionForkResponses, SessionGetErrors, SessionGetResponses, @@ -227,6 +238,8 @@ import type { SyncReplayErrors, SyncReplayResponses, SyncStartResponses, + SyncStealErrors, + SyncStealResponses, TelemetryCaptureErrors, TelemetryCaptureResponses, TelemetrySetEnabledErrors, @@ -261,8 +274,12 @@ import type { V2SessionMessagesResponses, V2SessionPromptResponses, V2SessionWaitResponses, + VcsApplyErrors, + VcsApplyResponses, + VcsDiffRawResponses, VcsDiffResponses, VcsGetResponses, + VcsStatusResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -1111,16 +1128,17 @@ export class Workspace extends HeyApiClient { } /** - * Restore session into workspace + * Warp session into workspace * - * Replay a session's sync events into the target workspace in batches. + * Move a session's sync history into the target workspace, or detach it to the local project. */ - public sessionRestore( - parameters: { - id: string + public warp( + parameters?: { directory?: string workspace?: string + id?: string | null sessionID?: string + copyChanges?: boolean }, options?: Options, ) { @@ -1129,20 +1147,21 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "id" }, { in: "body", key: "sessionID" }, + { in: "body", key: "copyChanges" }, ], }, ], ) return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, + ExperimentalWorkspaceWarpResponses, + ExperimentalWorkspaceWarpErrors, ThrowOnError >({ - url: "/experimental/workspace/{id}/session-restore", + url: "/experimental/workspace/warp", ...options, ...params, headers: { @@ -1752,6 +1771,38 @@ export class Path extends HeyApiClient { } } +export class Diff extends HeyApiClient { + /** + * Get raw VCS diff + * + * Retrieve a raw patch for current uncommitted changes. + */ + public raw( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff/raw", + ...options, + ...params, + }) + } +} + export class Vcs extends HeyApiClient { /** * Get VCS info @@ -1783,6 +1834,36 @@ export class Vcs extends HeyApiClient { }) } + /** + * Get VCS status + * + * Retrieve changed files in the current working tree without patches. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/status", + ...options, + ...params, + }) + } + /** * Get VCS diff * @@ -1814,6 +1895,48 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * Apply VCS patch + * + * Apply a raw patch to the current working tree. + */ + public apply( + parameters?: { + directory?: string + workspace?: string + patch?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/apply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _diff?: Diff + get diff2(): Diff { + return (this._diff ??= new Diff({ client: this.client })) + } } export class Command extends HeyApiClient { @@ -3617,7 +3740,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/session/{sessionID}/fork", ...options, ...params, @@ -4301,6 +4424,43 @@ export class Sync extends HeyApiClient { }) } + /** + * Steal session into workspace + * + * Update a session to belong to the current workspace through the sync event system. + */ + public steal( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/steal", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _history?: History get history(): History { return (this._history ??= new History({ client: this.client })) @@ -4962,6 +5122,214 @@ export class Tui extends HeyApiClient { } } +export class BackgroundProcess extends HeyApiClient { + /** + * List background processes + * + * List tracked background processes for the current instance. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/background-process", + ...options, + ...params, + }) + } + + /** + * Get background process + * + * Get status and retained output for one background process. + */ + public get( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "processID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + BackgroundProcessGetResponses, + BackgroundProcessGetErrors, + ThrowOnError + >({ + url: "/background-process/{processID}", + ...options, + ...params, + }) + } + + /** + * Get background process logs + * + * Get the retained output tail for one background process. + */ + public logs( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "processID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + BackgroundProcessLogsResponses, + BackgroundProcessLogsErrors, + ThrowOnError + >({ + url: "/background-process/{processID}/logs", + ...options, + ...params, + }) + } + + /** + * Stop background process + * + * Terminate a background process and its child process tree. + */ + public stop( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "processID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + BackgroundProcessStopResponses, + BackgroundProcessStopErrors, + ThrowOnError + >({ + url: "/background-process/{processID}/stop", + ...options, + ...params, + }) + } + + /** + * Restart background process + * + * Stop and restart a background process with its original command. + */ + public restart( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "processID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + BackgroundProcessRestartResponses, + BackgroundProcessRestartErrors, + ThrowOnError + >({ + url: "/background-process/{processID}/restart", + ...options, + ...params, + }) + } + + /** + * Stop session background processes + * + * Terminate and forget all background processes associated with one session. + */ + public stopSession( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/background-process/session/{sessionID}/stop", + ...options, + ...params, + }) + } +} + export class CommitMessage extends HeyApiClient { /** * Generate commit message @@ -6540,6 +6908,11 @@ export class KiloClient extends HeyApiClient { return (this._tui ??= new Tui({ client: this.client })) } + private _backgroundProcess?: BackgroundProcess + get backgroundProcess(): BackgroundProcess { + return (this._backgroundProcess ??= new BackgroundProcess({ client: this.client })) + } + private _commitMessage?: CommitMessage get commitMessage(): CommitMessage { return (this._commitMessage ??= new CommitMessage({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b0c8abb5540..a6d9eee91ff 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -27,6 +27,8 @@ export type Event = | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied + | EventBackgroundProcessUpdated + | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose | EventSessionDiff @@ -46,9 +48,9 @@ export type Event = | EventProjectUpdated | EventKilocodeAgentManagerStart | EventVcsBranchUpdated + | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -89,7 +91,6 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventKiloSessionsRemoteStatusChanged | EventIndexingStatus export type OAuth = { @@ -260,6 +261,26 @@ export type PermissionRequest = { } } +export type BackgroundProcessInfo = { + id: string + sessionID: string + pid?: number + command: string + cwd: string + description?: string + ports: Array + status: "starting" | "running" | "ready" | "exited" | "failed" | "stopping" | "stopped" + ready: boolean + exitCode?: number + signal?: string + output: string + time: { + started: number + updated: number + ended?: number + } +} + export type SnapshotFileDiff = { file: string patch: string @@ -873,6 +894,8 @@ export type GlobalEvent = { | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied + | EventBackgroundProcessUpdated + | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose | EventSessionDiff @@ -892,9 +915,9 @@ export type GlobalEvent = { | EventProjectUpdated | EventKilocodeAgentManagerStart | EventVcsBranchUpdated + | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -935,7 +958,6 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventKiloSessionsRemoteStatusChanged | EventIndexingStatus | SyncEventMessageUpdated | SyncEventMessageRemoved @@ -978,7 +1000,7 @@ export type GlobalEvent = { export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" /** - * Server configuration for kilo serve and web commands + * Server configuration for the kilo serve command */ export type ServerConfig = { port?: number @@ -1215,17 +1237,14 @@ export type ProviderConfig = { } export type McpLocalConfig = { - /** - * Type of MCP server connection - */ type: "local" - /** - * Command and arguments to run the MCP server - */ command: Array environment?: { [key: string]: string } + env?: { + [key: string]: string + } enabled?: boolean timeout?: number } @@ -1673,6 +1692,13 @@ export type VcsInfo = { default_branch?: string } +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + export type VcsFileDiff = { file: string patch: string @@ -1681,6 +1707,14 @@ export type VcsFileDiff = { status?: "added" | "deleted" | "modified" } +export type VcsApplyError = { + name: "VcsApplyError" + data: { + message: string + reason: "non-git" | "not-clean" + } +} + export type Command = { name: string description?: string @@ -1762,6 +1796,13 @@ export type McpUnsupportedOAuthError = { error: string } +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -1930,6 +1971,19 @@ export type Workspace = { projectID: string } +export type BackgroundProcessLogs = { + id: string + sessionID: string + output: string +} + +export type WorkspaceWarpError = { + name: "WorkspaceWarpError" + data: { + message: string + } +} + export type EffectHttpApiErrorUnauthorized = { _tag: "Unauthorized" } @@ -2090,9 +2144,11 @@ export type SyncEventSessionNextModelSwitched = { data: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -2163,7 +2219,7 @@ export type SyncEventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -2202,10 +2258,7 @@ export type SyncEventSessionNextStepFailed = { data: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2403,10 +2456,7 @@ export type SyncEventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -2616,6 +2666,23 @@ export type EventPermissionReplied = { } } +export type EventBackgroundProcessUpdated = { + id: string + type: "background_process.updated" + properties: { + info: BackgroundProcessInfo + } +} + +export type EventBackgroundProcessDeleted = { + id: string + type: "background_process.deleted" + properties: { + sessionID: string + processID: string + } +} + export type EventSessionTurnOpen = { id: string type: "session.turn.open" @@ -2802,6 +2869,15 @@ export type EventVcsBranchUpdated = { } } +export type EventKiloSessionsRemoteStatusChanged = { + id: string + type: "kilo-sessions.remote-status-changed" + properties: { + enabled: boolean + connected: boolean + } +} + export type EventWorkspaceReady = { id: string type: "workspace.ready" @@ -2818,17 +2894,6 @@ export type EventWorkspaceFailed = { } } -export type EventWorkspaceRestore = { - id: string - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - export type EventWorkspaceStatus = { id: string type: "workspace.status" @@ -2969,9 +3034,11 @@ export type EventSessionNextModelSwitched = { properties: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -3046,7 +3113,7 @@ export type EventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -3073,16 +3140,18 @@ export type EventSessionNextStepEnded = { } } +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + export type EventSessionNextStepFailed = { id: string type: "session.next.step.failed" properties: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -3253,10 +3322,7 @@ export type EventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -3321,15 +3387,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventKiloSessionsRemoteStatusChanged = { - id: string - type: "kilo-sessions.remote-status-changed" - properties: { - enabled: boolean - connected: boolean - } -} - export type EventIndexingStatus = { id: string type: "indexing.status" @@ -3348,7 +3405,7 @@ export type SessionInfo = { model?: { id: string providerID: string - variant?: string + variant: string } time: { created: number @@ -3384,7 +3441,7 @@ export type SessionMessageModelSwitched = { model: { id: string providerID: string - variant?: string + variant: string } } @@ -3478,10 +3535,7 @@ export type SessionMessageToolStateError = { structured: { [key: string]: unknown } - error: { - type: string - message: string - } + error: SessionErrorUnknown } export type SessionMessageAssistantTool = { @@ -3521,7 +3575,7 @@ export type SessionMessageAssistant = { model: { id: string providerID: string - variant?: string + variant: string } content: Array snapshot?: { @@ -3539,10 +3593,7 @@ export type SessionMessageAssistant = { write: number } } - error?: { - type: string - message: string - } + error?: SessionErrorUnknown } export type SessionMessageCompaction = { @@ -3587,13 +3638,6 @@ export type BadRequestError = { success: false } -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type AuthRemoveData = { body?: never path: { @@ -4496,6 +4540,25 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/status" +} + +export type VcsStatusResponses = { + /** + * VCS status + */ + 200: Array +} + +export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses] + export type VcsDiffData = { body?: never path?: never @@ -4516,6 +4579,57 @@ export type VcsDiffResponses = { export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] +export type VcsDiffRawData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/diff/raw" +} + +export type VcsDiffRawResponses = { + /** + * Raw VCS diff + */ + 200: string +} + +export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses] + +export type VcsApplyData = { + body?: { + patch: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/apply" +} + +export type VcsApplyErrors = { + /** + * VcsApplyError + */ + 400: VcsApplyError +} + +export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors] + +export type VcsApplyResponses = { + /** + * VCS patch applied + */ + 200: { + applied: boolean + } +} + +export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] + export type CommandListData = { body?: never path?: never @@ -5047,7 +5161,7 @@ export type PtyRemoveData = { export type PtyRemoveErrors = { /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5077,7 +5191,7 @@ export type PtyGetData = { export type PtyGetErrors = { /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5147,7 +5261,7 @@ export type PtyConnectTokenErrors = { */ 403: EffectHttpApiErrorForbidden /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5619,7 +5733,7 @@ export type SessionDeleteErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5653,7 +5767,7 @@ export type SessionGetErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5693,7 +5807,7 @@ export type SessionUpdateErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5819,7 +5933,7 @@ export type SessionMessagesErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5950,7 +6064,7 @@ export type SessionMessageErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5983,6 +6097,15 @@ export type SessionForkData = { url: "/session/{sessionID}/fork" } +export type SessionForkErrors = { + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] + export type SessionForkResponses = { /** * 200 @@ -6082,7 +6205,7 @@ export type SessionUnshareErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6116,7 +6239,7 @@ export type SessionShareErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6154,7 +6277,7 @@ export type SessionSummarizeErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6584,6 +6707,38 @@ export type SyncReplayResponses = { export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] +export type SyncStealData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/steal" +} + +export type SyncStealErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] + +export type SyncStealResponses = { + /** + * Session stolen into workspace + */ + 200: { + sessionID: string + } +} + +export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] + export type SyncHistoryListData = { body?: { [key: string]: number @@ -7014,7 +7169,7 @@ export type TuiSelectSessionErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -7205,41 +7360,200 @@ export type ExperimentalWorkspaceRemoveResponses = { export type ExperimentalWorkspaceRemoveResponse = ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] -export type ExperimentalWorkspaceSessionRestoreData = { +export type ExperimentalWorkspaceWarpData = { body?: { + id: string | null sessionID: string + copyChanges?: boolean } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/warp" +} + +export type ExperimentalWorkspaceWarpErrors = { + /** + * WorkspaceWarpError | VcsApplyError + */ + 400: WorkspaceWarpError | VcsApplyError +} + +export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] + +export type ExperimentalWorkspaceWarpResponses = { + /** + * Session warped + */ + 204: void +} + +export type ExperimentalWorkspaceWarpResponse = + ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] + +export type BackgroundProcessListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/background-process" +} + +export type BackgroundProcessListResponses = { + /** + * List of background processes + */ + 200: Array +} + +export type BackgroundProcessListResponse = BackgroundProcessListResponses[keyof BackgroundProcessListResponses] + +export type BackgroundProcessGetData = { + body?: never path: { - id: string + processID: string } query?: { directory?: string workspace?: string } - url: "/experimental/workspace/{id}/session-restore" + url: "/background-process/{processID}" } -export type ExperimentalWorkspaceSessionRestoreErrors = { +export type BackgroundProcessGetErrors = { /** - * Bad request + * Not found */ - 400: BadRequestError + 404: NotFoundError } -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] +export type BackgroundProcessGetError = BackgroundProcessGetErrors[keyof BackgroundProcessGetErrors] -export type ExperimentalWorkspaceSessionRestoreResponses = { +export type BackgroundProcessGetResponses = { /** - * Session replay started + * Background process info */ - 200: { - total: number + 200: BackgroundProcessInfo +} + +export type BackgroundProcessGetResponse = BackgroundProcessGetResponses[keyof BackgroundProcessGetResponses] + +export type BackgroundProcessLogsData = { + body?: never + path: { + processID: string } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}/logs" } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type BackgroundProcessLogsErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessLogsError = BackgroundProcessLogsErrors[keyof BackgroundProcessLogsErrors] + +export type BackgroundProcessLogsResponses = { + /** + * Background process logs + */ + 200: BackgroundProcessLogs +} + +export type BackgroundProcessLogsResponse = BackgroundProcessLogsResponses[keyof BackgroundProcessLogsResponses] + +export type BackgroundProcessStopData = { + body?: never + path: { + processID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}/stop" +} + +export type BackgroundProcessStopErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessStopError = BackgroundProcessStopErrors[keyof BackgroundProcessStopErrors] + +export type BackgroundProcessStopResponses = { + /** + * Stopped background process + */ + 200: BackgroundProcessInfo +} + +export type BackgroundProcessStopResponse = BackgroundProcessStopResponses[keyof BackgroundProcessStopResponses] + +export type BackgroundProcessRestartData = { + body?: never + path: { + processID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}/restart" +} + +export type BackgroundProcessRestartErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessRestartError = BackgroundProcessRestartErrors[keyof BackgroundProcessRestartErrors] + +export type BackgroundProcessRestartResponses = { + /** + * Restarted background process + */ + 200: BackgroundProcessInfo +} + +export type BackgroundProcessRestartResponse = + BackgroundProcessRestartResponses[keyof BackgroundProcessRestartResponses] + +export type BackgroundProcessStopSessionData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/session/{sessionID}/stop" +} + +export type BackgroundProcessStopSessionResponses = { + /** + * Stopped session background processes + */ + 200: boolean +} + +export type BackgroundProcessStopSessionResponse = + BackgroundProcessStopSessionResponses[keyof BackgroundProcessStopSessionResponses] export type CommitMessageGenerateData = { body?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e1d98a72b22..89bd016ba98 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2176,6 +2176,54 @@ ] } }, + "/vcs/status": { + "get": { + "tags": ["instance"], + "operationId": "vcs.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileStatus" + }, + "description": "VCS status" + } + } + } + } + }, + "description": "Retrieve changed files in the current working tree without patches.", + "summary": "Get VCS status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.vcs.status({\n ...\n})" + } + ] + } + }, "/vcs/diff": { "get": { "tags": ["instance"], @@ -2233,6 +2281,128 @@ ] } }, + "/vcs/diff/raw": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff.raw", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Raw VCS diff", + "content": { + "text/x-diff; charset=utf-8": { + "schema": { + "type": "string" + } + } + } + } + }, + "description": "Retrieve a raw patch for current uncommitted changes.", + "summary": "Get raw VCS diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.vcs.diff.raw({\n ...\n})" + } + ] + } + }, + "/vcs/apply": { + "post": { + "tags": ["instance"], + "operationId": "vcs.apply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS patch applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "boolean" + } + }, + "required": ["applied"], + "additionalProperties": false, + "description": "VCS patch applied" + } + } + } + }, + "400": { + "description": "VcsApplyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsApplyError" + } + } + } + } + }, + "description": "Apply a raw patch to the current working tree.", + "summary": "Apply VCS patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "patch": { + "type": "string" + } + }, + "required": ["patch"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.vcs.apply({\n ...\n})" + } + ] + } + }, "/command": { "get": { "tags": ["instance"], @@ -3520,7 +3690,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -3673,7 +3843,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -3758,7 +3928,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4917,7 +5087,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4989,7 +5159,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5060,7 +5230,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5415,7 +5585,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5687,7 +5857,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5829,6 +5999,16 @@ } } } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } } }, "description": "Create a new session by forking an existing session at a specific message point.", @@ -6079,7 +6259,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6150,7 +6330,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6224,7 +6404,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -7365,6 +7545,84 @@ ] } }, + "/sync/steal": { + "post": { + "tags": ["sync"], + "operationId": "sync.steal", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session stolen into workspace", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false, + "description": "Session stolen into workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update a session to belong to the current workspace through the sync event system.", + "summary": "Steal session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sync.steal({\n ...\n})" + } + ] + } + }, "/sync/history": { "post": { "tags": ["sync"], @@ -8399,7 +8657,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -8861,10 +9119,141 @@ ] } }, - "/experimental/workspace/{id}/session-restore": { + "/experimental/workspace/warp": { "post": { "tags": ["workspace"], - "operationId": "experimental.workspace.sessionRestore", + "operationId": "experimental.workspace.warp", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Session warped" + }, + "400": { + "description": "WorkspaceWarpError | VcsApplyError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceWarpError" + }, + { + "$ref": "#/components/schemas/VcsApplyError" + } + ] + } + } + } + } + }, + "description": "Move a session's sync history into the target workspace, or detach it to the local project.", + "summary": "Warp session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sessionID": { + "type": "string" + }, + "copyChanges": { + "type": "boolean" + } + }, + "required": ["id", "sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.warp({\n ...\n})" + } + ] + } + }, + "/background-process": { + "get": { + "tags": ["background-process"], + "operationId": "backgroundProcess.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of background processes", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + }, + "description": "List of background processes" + } + } + } + } + }, + "description": "List tracked background processes for the current instance.", + "summary": "List background processes", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.list({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}": { + "get": { + "tags": ["background-process"], + "operationId": "backgroundProcess.get", "parameters": [ { "name": "directory", @@ -8883,68 +9272,286 @@ } }, { - "name": "id", + "name": "processID", "in": "path", "schema": { "type": "string", - "pattern": "^wrk.*" + "pattern": "^bgp.*" }, "required": true } ], "responses": { "200": { - "description": "Session replay started", + "description": "Background process info", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["total"], - "additionalProperties": false, - "description": "Session replay started" + "$ref": "#/components/schemas/BackgroundProcessInfo" } } } }, - "400": { - "description": "Bad request", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/NotFoundError" } } } } }, - "description": "Replay a session's sync events into the target workspace in batches.", - "summary": "Restore session into workspace", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - } - }, - "required": ["sessionID"], - "additionalProperties": false + "description": "Get status and retained output for one background process.", + "summary": "Get background process", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.get({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}/logs": { + "get": { + "tags": ["background-process"], + "operationId": "backgroundProcess.logs", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Background process logs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessLogs" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } } } } }, + "description": "Get the retained output tail for one background process.", + "summary": "Get background process logs", "x-codeSamples": [ { "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.logs({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}/stop": { + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.stop", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Stopped background process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Terminate a background process and its child process tree.", + "summary": "Stop background process", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.stop({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}/restart": { + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.restart", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Restarted background process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Stop and restart a background process with its original command.", + "summary": "Restart background process", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.restart({\n ...\n})" + } + ] + } + }, + "/background-process/session/{sessionID}/stop": { + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.stopSession", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Stopped session background processes", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Stopped session background processes" + } + } + } + } + }, + "description": "Terminate and forget all background processes associated with one session.", + "summary": "Stop session background processes", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.stopSession({\n ...\n})" } ] } @@ -12192,6 +12799,12 @@ { "$ref": "#/components/schemas/EventPermissionReplied" }, + { + "$ref": "#/components/schemas/EventBackground_processUpdated" + }, + { + "$ref": "#/components/schemas/EventBackground_processDeleted" + }, { "$ref": "#/components/schemas/EventSessionTurnOpen" }, @@ -12249,15 +12862,15 @@ { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, + { + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + }, { "$ref": "#/components/schemas/EventWorkspaceReady" }, { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -12378,9 +12991,6 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, - { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" - }, { "$ref": "#/components/schemas/EventIndexingStatus" } @@ -12807,6 +13417,75 @@ "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], "additionalProperties": false }, + "BackgroundProcessInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "pid": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ports": { + "type": "array", + "items": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "status": { + "type": "string", + "enum": ["starting", "running", "ready", "exited", "failed", "stopping", "stopped"] + }, + "ready": { + "type": "boolean" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + }, + "signal": { + "type": "string" + }, + "output": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "started": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "ended": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["started", "updated"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "command", "cwd", "ports", "status", "ready", "output", "time"], + "additionalProperties": false + }, "SnapshotFileDiff": { "type": "object", "properties": { @@ -14628,6 +15307,12 @@ { "$ref": "#/components/schemas/EventPermissionReplied" }, + { + "$ref": "#/components/schemas/EventBackground_processUpdated" + }, + { + "$ref": "#/components/schemas/EventBackground_processDeleted" + }, { "$ref": "#/components/schemas/EventSessionTurnOpen" }, @@ -14685,15 +15370,15 @@ { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, + { + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + }, { "$ref": "#/components/schemas/EventWorkspaceReady" }, { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -14814,9 +15499,6 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, - { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" - }, { "$ref": "#/components/schemas/EventIndexingStatus" }, @@ -16894,6 +17576,28 @@ }, "additionalProperties": false }, + "VcsFileStatus": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["file", "additions", "deletions", "status"], + "additionalProperties": false + }, "VcsFileDiff": { "type": "object", "properties": { @@ -16919,6 +17623,31 @@ "required": ["file", "patch", "additions", "deletions"], "additionalProperties": false }, + "VcsApplyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["VcsApplyError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["non-git", "not-clean"] + } + }, + "required": ["message", "reason"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "Command": { "type": "object", "properties": { @@ -17149,6 +17878,25 @@ "required": ["error"], "additionalProperties": false }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } + }, "effect_HttpApiError_Forbidden": { "type": "object", "properties": { @@ -17647,6 +18395,43 @@ "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], "additionalProperties": false }, + "BackgroundProcessLogs": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["id", "sessionID", "output"], + "additionalProperties": false + }, + "WorkspaceWarpError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceWarpError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "effect_HttpApiError_Unauthorized": { "type": "object", "properties": { @@ -18288,17 +19073,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -18521,7 +19313,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -18647,17 +19439,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -19269,17 +20051,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -19884,6 +20656,57 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventBackground_processUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["background_process.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventBackground_processDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["background_process.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "processID": { + "type": "string" + } + }, + "required": ["sessionID", "processID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionTurnOpen": { "type": "object", "properties": { @@ -20439,6 +21262,33 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventKilo-sessionsRemote-status-changed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilo-sessions.remote-status-changed"] + }, + "properties": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "connected": { + "type": "boolean" + } + }, + "required": ["enabled", "connected"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventWorkspaceReady": { "type": "object", "properties": { @@ -20487,41 +21337,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventWorkspaceRestore": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.restore"] - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string" - }, - "sessionID": { - "type": "string" - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "step": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventWorkspaceStatus": { "type": "object", "properties": { @@ -20946,17 +21761,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -21175,7 +21997,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -21259,6 +22081,20 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "SessionErrorUnknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, "EventSessionNextStepFailed": { "type": "object", "properties": { @@ -21279,17 +22115,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -21792,17 +22618,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -21985,33 +22801,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventKilo-sessionsRemote-status-changed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["kilo-sessions.remote-status-changed"] - }, - "properties": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "connected": { - "type": "boolean" - } - }, - "required": ["enabled", "connected"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventIndexingStatus": { "type": "object", "properties": { @@ -22070,7 +22859,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "time": { @@ -22166,7 +22955,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false } }, @@ -22425,17 +23214,7 @@ "type": "object" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["status", "input", "content", "structured", "error"], @@ -22548,7 +23327,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "content": { @@ -22615,17 +23394,7 @@ "additionalProperties": false }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["id", "time", "type", "agent", "model", "content"], @@ -22745,25 +23514,6 @@ "enum": [false] } } - }, - "NotFoundError": { - "type": "object", - "required": ["name", "data"], - "properties": { - "name": { - "type": "string", - "enum": ["NotFoundError"] - }, - "data": { - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string" - } - } - } - } } } }, @@ -22845,6 +23595,10 @@ "name": "workspace", "description": "Experimental HttpApi workspace routes." }, + { + "name": "background-process", + "description": "Kilo background process routes." + }, { "name": "commit-message", "description": "Kilo commit message routes." diff --git a/packages/storybook/package.json b/packages/storybook/package.json index fb79b34f83d..f957da61330 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.3.7", + "version": "7.3.8", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index c03773f120e..ef96a8cdb60 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.3.7", + "version": "7.3.8", "type": "module", "license": "MIT", "exports": { @@ -25,6 +25,8 @@ }, "scripts": { "typecheck": "tsgo --noEmit", + "test": "bun test src", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index ad05555bdf6..6f11ca2433d 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -128,8 +128,12 @@ function DiffSSRViewer(props: SSRDiffFileProps) { prerenderedHTML: local.preloadedDiff.prerenderedHTML, } : { - oldFile: local.before, - newFile: local.after, + oldFile: local.before + ? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" } + : local.before, + newFile: local.after + ? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" } + : local.after, lineAnnotations: annotations, fileContainer: fileDiffRef, containerWrapper: container, diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index bde83b24b58..fc2067a342e 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -35,6 +35,8 @@ const config = { SANITIZE_NAMED_PROPS: true, FORBID_TAGS: ["style"], FORBID_CONTENTS: ["style", "script"], + ADD_TAGS: ["svg", "path"], + ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns"], } const iconPaths = { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 5d5f8fe4013..7a64a5ff5e8 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1944,11 +1944,11 @@ ToolRegistry.register({ mode="diff" before={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.before || props.input.oldString, + contents: props.metadata?.filediff?.before || props.input.oldString || "", }} after={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.after || props.input.newString, + contents: props.metadata?.filediff?.after || props.input.newString || "", }} /> diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts index bb8a45e660d..2f22f2e1bb6 100644 --- a/packages/ui/src/components/provider-icons/types.ts +++ b/packages/ui/src/components/provider-icons/types.ts @@ -7,12 +7,12 @@ export const iconNames = [ "zai", "zai-coding-plan", "xiaomi", - "xiaomi-token-plan-sgp", // kilocode_change - "xiaomi-token-plan-cn", // kilocode_change - "xiaomi-token-plan-ams", // kilocode_change + "xiaomi-token-plan-sgp", + "xiaomi-token-plan-cn", + "xiaomi-token-plan-ams", "xai", "wandb", - "wafer.ai", // kilocode_change + "wafer.ai", "vultr", "vivgrid", "vercel", @@ -20,8 +20,8 @@ export const iconNames = [ "v0", "upstage", "togetherai", - "the-grid-ai", // kilocode_change - "tencent-tokenhub", // kilocode_change + "the-grid-ai", + "tencent-tokenhub", "tencent-coding-plan", "synthetic", "submodel", @@ -32,7 +32,7 @@ export const iconNames = [ "scaleway", "sap-ai-core", "requesty", - "regolo-ai", // kilocode_change + "regolo-ai", "qiniu-ai", "qihang-ai", "privatemode-ai", @@ -48,7 +48,7 @@ export const iconNames = [ "nvidia", "novita-ai", "nova", - "neuralwatt", // kilocode_change + "neuralwatt", "nebius", "nano-gpt", "morph", @@ -56,7 +56,7 @@ export const iconNames = [ "moonshotai-cn", "modelscope", "moark", - "mixlayer", // kilocode_change + "mixlayer", "mistral", "minimax", "minimax-coding-plan", @@ -65,10 +65,10 @@ export const iconNames = [ "meganova", "lucidquery", "lmstudio", - "llmgateway", // kilocode_change + "llmgateway", "llama", "kuae-cloud-coding-plan", - "kiro", // kilocode_change + "kiro", "kimi-for-coding", "kilo", "jiekou", @@ -77,7 +77,7 @@ export const iconNames = [ "inception", "iflowcn", "huggingface", - "hpc-ai", // kilocode_change + "hpc-ai", "helicone", "groq", "google", @@ -86,7 +86,7 @@ export const iconNames = [ "gitlab", "github-models", "github-copilot", - "frogbot", // kilocode_change + "frogbot", "friendli", "firmware", "fireworks-ai", @@ -94,7 +94,7 @@ export const iconNames = [ "evroc", "drun", "dinference", - "digitalocean", // kilocode_change + "digitalocean", "deepseek", "deepinfra", "cortecs", @@ -117,7 +117,7 @@ export const iconNames = [ "alibaba-coding-plan-cn", "alibaba-cn", "aihubmix", - "abliteration-ai", // kilocode_change + "abliteration-ai", "abacus", "302ai", ] as const diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index 463a729778d..172fe8d6c2b 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -19,6 +19,21 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("one\nthree\n") }) + test("keeps missing final newlines from unified patches", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(text(view, "deletions")).toBe("one\ntwo") + expect(text(view, "additions")).toBe("one\nthree") + }) + test("converts legacy content into a patch", () => { const diff = { file: "a.ts", @@ -34,4 +49,20 @@ describe("session diff", () => { expect(text(view, "deletions")).toBe("one\n") expect(text(view, "additions")).toBe("two\n") }) + + test("ignores malformed persisted patches", () => { + const diff = { + file: "a.ts", + patch: + "diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) }) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 26c45cb3214..7bd43ceda18 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -36,26 +36,49 @@ const cache = new Map() export function contents(diff: ReviewDiff): DiffText { if (typeof diff.patch === "string") { - const [patch] = parsePatch(diff.patch) + try { + const [patch] = parsePatch(diff.patch) + const beforeLines: Array<{ text: string; newline: boolean }> = [] + const afterLines: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined - const beforeLines = [] - const afterLines = [] + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("\\")) { + if (previous === "-" || previous === " ") { + const before = beforeLines.at(-1) + if (before) before.newline = false + } + if (previous === "+" || previous === " ") { + const after = afterLines.at(-1) + if (after) after.newline = false + } + continue + } - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) - } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) - } else { - // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + if (line.startsWith("-")) { + beforeLines.push({ text: line.slice(1), newline: true }) + previous = "-" + } else if (line.startsWith("+")) { + afterLines.push({ text: line.slice(1), newline: true }) + previous = "+" + } else { + // context line (starts with ' ') + beforeLines.push({ text: line.slice(1), newline: true }) + afterLines.push({ text: line.slice(1), newline: true }) + previous = " " + } } } - } - return { before: beforeLines.join("\n") + "\n", after: afterLines.join("\n") + "\n", patch: diff.patch } + return { + before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + patch: diff.patch, + } + } catch { + return { before: "", after: "", patch: diff.patch } + } } return { before: "before" in diff && typeof diff.before === "string" ? diff.before : "", diff --git a/packages/ui/src/file-path.test.ts b/packages/ui/src/file-path.test.ts index 16e8dc47627..a2df1f15f06 100644 --- a/packages/ui/src/file-path.test.ts +++ b/packages/ui/src/file-path.test.ts @@ -1,3 +1,4 @@ +// kilocode_change - new file import { describe, expect, it } from "bun:test" import { parseFilePath, extractFilePathFromHref } from "./file-path" @@ -154,6 +155,18 @@ describe("extractFilePathFromHref", () => { it("path with multiple extensions", () => { expect(extractFilePathFromHref("config.test.ts")).toBe("config.test.ts") }) + + it("file:// URL on Unix", () => { + expect(extractFilePathFromHref("file:///foo/bar.ts")).toBe("/foo/bar.ts") + }) + + it("file:// URL with Windows drive", () => { + expect(extractFilePathFromHref("file:///C:/Users/dev/file.ts")).toBe("C:/Users/dev/file.ts") + }) + + it("file:// URL with encoded characters", () => { + expect(extractFilePathFromHref("file:///foo%20bar/baz.ts")).toBe("/foo bar/baz.ts") + }) }) describe("strips fragments and queries", () => { @@ -195,10 +208,6 @@ describe("extractFilePathFromHref", () => { expect(extractFilePathFromHref("javascript:void(0)")).toBeUndefined() }) - it("file:// URL", () => { - expect(extractFilePathFromHref("file:///foo/bar.ts")).toBeUndefined() - }) - it("ftp URL", () => { expect(extractFilePathFromHref("ftp://server/file.txt")).toBeUndefined() }) diff --git a/script/publish.ts b/script/publish.ts index 3ca9b355ae6..9e676d0a13a 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -129,6 +129,13 @@ console.log("\n=== vscode ===\n") await import(`../packages/kilo-vscode/script/publish.ts`) // kilocode_change end +// kilocode_change start - Kilo does not ship the opencode desktop app +// if (Script.release) { +// await $`bun ./packages/desktop/scripts/finalize-latest-json.ts` +// await $`bun ./packages/desktop/scripts/finalize-latest-yml.ts` +// } +// kilocode_change end + const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) diff --git a/script/upstream/package.json b/script/upstream/package.json index 11c0aa1d0c7..e56392d8676 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.3.7", + "version": "7.3.8", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md deleted file mode 100644 index 20d84c8f474..00000000000 --- a/specs/v2/session-concepts-gap.md +++ /dev/null @@ -1,131 +0,0 @@ -# Session V2 Concept Gaps - -Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. - -## Message Metadata - -- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. -- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. - -## Output Format - -- Text output format. -- JSON-schema output format. -- Structured-output retry count. -- Structured assistant result payload. -- Structured-output error classification. - -## Errors - -- Aborted error. -- Provider auth error. -- API error with status, retryability, headers, body, and metadata. -- Context-overflow error. -- Output-length error. -- Unknown error. -- V2 mostly reduces assistant errors to strings, except retry errors. - -## Part Identity - -- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. -- V2 assistant content does not preserve stable per-content IDs. -- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. - -## Part Timing And Metadata - -- V1 text, reasoning, and tool states carry timing and provider metadata. -- V2 assistant text and reasoning content only store text. -- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. - -## Snapshots And Patches - -- Snapshot parts. -- Patch parts. -- Step-start snapshot references. -- Step-finish snapshot references. -- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. - -## Step Boundaries - -- V1 stores `step-start` and `step-finish` as first-class parts. -- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. -- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. - -## Compaction - -- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. -- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. -- V1 also has history filtering semantics around completed summary messages and retained tails. - -## Files And Sources - -- V1 file parts have `mime`, `filename`, `url`, and typed source information. -- V1 source variants include file, symbol, and resource sources. -- Symbol sources include LSP range, name, and kind. -- Resource sources include client name and URI. -- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. - -## Agents And Subtasks - -- Agent parts. -- Subtask parts. -- Subtask prompt, description, agent, model, and command. -- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. - -## Text Flags - -- Synthetic text flag. -- Ignored text flag. -- V2 has a separate synthetic entry, but no ignored text concept. - -## Tool Calls - -- V1 pending tool state stores parsed input and raw input text separately. -- V2 pending tool state stores a string input but does not preserve a separate raw field. -- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. -- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. -- V1 error tool state has `time.start` and `time.end`. -- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. -- V1 tracks provider execution and provider call metadata. -- V2 events include provider info, but `SessionEntryStepper` drops it from entries. -- V1 has tool-output compaction and truncation behavior via `time.compacted`. - -## Media Handling - -- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. -- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. -- V2 has attachments but not these model-message conversion semantics. - -## Retries - -- V1 stores retries as independently addressable retry parts. -- V2 stores retries as an assistant aggregate. -- V2 captures some retry information, but not the independent part identity/update model. - -## Processor Control Flow - -- Session status transitions: busy, retry, and idle. -- Retry policy integration. -- Context-overflow-driven compaction. -- Abort and interrupt handling. -- Permission-denied blocking. -- Doom-loop detection. -- Plugin hook for `experimental.text.complete`. -- Background summary generation after steps. -- Cleanup semantics for open text, reasoning, and tool calls. - -## Sync And Bus Events - -- Message updated. -- Message removed. -- Message part updated. -- Message part delta. -- Message part removed. -- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. - -## History Retrieval - -- Cursor encoding and decoding. -- Paged message retrieval. -- Reverse streaming through history. -- Compaction-aware history filtering. diff --git a/specs/v2/todo.md b/specs/v2/todo.md new file mode 100644 index 00000000000..77c650e55fc --- /dev/null +++ b/specs/v2/todo.md @@ -0,0 +1,59 @@ +# TODO + +ok we need to work towards a launch of v2 so we can get out of this rebuild phase + +## Kill Hono - Kit + +Hono needs to go away so zod can go away. this is almost done + +## New Data Mode - Dax + +This is mostly done. I'm working through modeling subagents, skill invocations +and shell commands. + +## Rework agent loop - Kit? + +I think this needs to be done so we can take advantage of the simpler data +model. It can stop doing all the + +## Rework compaction - Aiden? + +The new agent loop needs to trigger compaction properly + +## Plugin API design - James? + +We need to figure out how we want server plugins to work and what hooks are useful. + +Some ideas: + +- plugins get immer drafts so bad mutations can be thrown away +- plugins get global "opencode" instance like in that post i showed +- opencode instance has stuff like `opencode.session.prompt()` or + `opencode.tool.register({...})` + +## Rework Config - ??? + +We should do another pass on config to clean up any mistakes we made with it and +simplify as much as possible. Old configs should get auto-converted to new + +## Auth - ??? + +I have a basic auth system that can track any kind of auth, not just providers + +## Model Database - ??? + +I have a basic model service that allows for models to be registered dynamically + +## Provider - ??? + +Providers should register as plugins and autoload based on whatever logic they +want / config. They should register models into model database + +## Event - Kit + +I have this v2/event.ts but it needs to be self contained instead of using the +old bus system + +## Everything is hotreloadable - ??? + +Instead of needing to tear down things when something changes every service should emit granular events so services can react to them and reconfigure themselves. Allows frontend to receive these too, eg model.added. also prevents startup from blocking diff --git a/turbo.json b/turbo.json index cbf33fd23a9..c1798ac2a7e 100644 --- a/turbo.json +++ b/turbo.json @@ -37,8 +37,16 @@ "outputs": [] }, "@kilocode/kilo-jetbrains#test:ci": { - "dependsOn": ["@kilocode/kilo-jetbrains#typecheck"], "outputs": [".artifacts/unit/junit.xml"] + }, + "@opencode-ai/ui#test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "@opencode-ai/ui#test:ci": { + "dependsOn": ["^build"], + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] } } }