diff --git a/.changeset/am-dialog-popover-clipping.md b/.changeset/am-dialog-popover-clipping.md new file mode 100644 index 00000000000..66fc9803756 --- /dev/null +++ b/.changeset/am-dialog-popover-clipping.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the reasoning-variant and mode dropdowns being clipped inside the New Worktree dialog. The dialog's scroll-container overflow escape now covers all inline selector popovers, not just the model picker, so dropdowns render fully above the prompt input. diff --git a/.changeset/commit-message-no-changes-error.md b/.changeset/commit-message-no-changes-error.md new file mode 100644 index 00000000000..f012b297238 --- /dev/null +++ b/.changeset/commit-message-no-changes-error.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Show a clear "No changes found to generate a commit message for" error instead of a generic "Unexpected server error" when there is nothing to commit. The endpoint now returns a typed 422, and the extension surfaces the real message directly. diff --git a/.changeset/config-file-substitution-trust.md b/.changeset/config-file-substitution-trust.md new file mode 100644 index 00000000000..3bbc2bfd358 --- /dev/null +++ b/.changeset/config-file-substitution-trust.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Harden config credential substitution against untrusted project config. Environment references (`{env:VAR}`) now resolve only in trusted config (global config, `KILO_CONFIG`, `KILO_CONFIG_CONTENT`, and org/MDM-managed config); a project-committed `kilo.json` / `opencode.json` can no longer use them. File references (`{file:...}`) still work in project config but are confined to the project root, so absolute paths, `../` traversal, and symlink escapes are rejected. This closes a path where a malicious repository could exfiltrate local secrets to an attacker-controlled `baseURL`. diff --git a/.changeset/fix-agent-manager-prompt-bidi.md b/.changeset/fix-agent-manager-prompt-bidi.md new file mode 100644 index 00000000000..6da36fd109b --- /dev/null +++ b/.changeset/fix-agent-manager-prompt-bidi.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support bidirectional text in the Agent Manager new worktree prompt. diff --git a/.changeset/fix-prompt-bidi-multiline.md b/.changeset/fix-prompt-bidi-multiline.md new file mode 100644 index 00000000000..c20e46b5e04 --- /dev/null +++ b/.changeset/fix-prompt-bidi-multiline.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix multiline bidirectional prompt input rendering and file mention arrow navigation. diff --git a/.changeset/fix-settings-sidebar-i18n-width.md b/.changeset/fix-settings-sidebar-i18n-width.md new file mode 100644 index 00000000000..60c75117be4 --- /dev/null +++ b/.changeset/fix-settings-sidebar-i18n-width.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix settings sidebar truncating localized section labels in non-English languages. diff --git a/.changeset/inline-read-images.md b/.changeset/inline-read-images.md new file mode 100644 index 00000000000..7d4a511f0d0 --- /dev/null +++ b/.changeset/inline-read-images.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Render images inline in the chat view when the agent reads an image file. Images appear below the read tool card and can be clicked to open a full-size preview. diff --git a/.changeset/jetbrains-code-block-inset.md b/.changeset/jetbrains-code-block-inset.md new file mode 100644 index 00000000000..3e843b59228 --- /dev/null +++ b/.changeset/jetbrains-code-block-inset.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session and code block padding alignment. diff --git a/.changeset/jetbrains-download-cli.md b/.changeset/jetbrains-download-cli.md new file mode 100644 index 00000000000..82f2b3bb0bd --- /dev/null +++ b/.changeset/jetbrains-download-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Reduce JetBrains plugin size by downloading the Kilo Core release on first connect. diff --git a/.changeset/jetbrains-popup-preview-size.md b/.changeset/jetbrains-popup-preview-size.md new file mode 100644 index 00000000000..89ca4ae7a3d --- /dev/null +++ b/.changeset/jetbrains-popup-preview-size.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Cap JetBrains reasoning and shell hover previews to a compact popup size. diff --git a/.changeset/jetbrains-progress-foreground.md b/.changeset/jetbrains-progress-foreground.md new file mode 100644 index 00000000000..2cf3f47eebf --- /dev/null +++ b/.changeset/jetbrains-progress-foreground.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match the JetBrains progress text color to transcript text. diff --git a/.changeset/jetbrains-prompt-focus-separator.md b/.changeset/jetbrains-prompt-focus-separator.md new file mode 100644 index 00000000000..35a014a4d57 --- /dev/null +++ b/.changeset/jetbrains-prompt-focus-separator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Hide the JetBrains prompt separator while the prompt is focused. diff --git a/.changeset/jetbrains-prompt-input-inset.md b/.changeset/jetbrains-prompt-input-inset.md new file mode 100644 index 00000000000..443bbd6866d --- /dev/null +++ b/.changeset/jetbrains-prompt-input-inset.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Balance JetBrains prompt input text padding. diff --git a/.changeset/jetbrains-prompt-spellcheck.md b/.changeset/jetbrains-prompt-spellcheck.md new file mode 100644 index 00000000000..bf39458d371 --- /dev/null +++ b/.changeset/jetbrains-prompt-spellcheck.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Disable spellchecking in the JetBrains prompt input. diff --git a/.changeset/jetbrains-prune-old-cli.md b/.changeset/jetbrains-prune-old-cli.md new file mode 100644 index 00000000000..07600c3f7eb --- /dev/null +++ b/.changeset/jetbrains-prune-old-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Remove old JetBrains CLI binaries so they no longer accumulate in the IDE cache. Only the active version is kept, the downloaded archive is deleted after extraction, and reinstalling re-downloads a fresh binary. diff --git a/.changeset/jetbrains-reasoning-view.md b/.changeset/jetbrains-reasoning-view.md new file mode 100644 index 00000000000..e10fa891682 --- /dev/null +++ b/.changeset/jetbrains-reasoning-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Auto-collapse JetBrains reasoning blocks when they finish streaming, keep manual expand/collapse choices, and preview collapsed reasoning on hover. diff --git a/.changeset/jetbrains-session-background.md b/.changeset/jetbrains-session-background.md new file mode 100644 index 00000000000..7b76c7ba61f --- /dev/null +++ b/.changeset/jetbrains-session-background.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use the session background across the JetBrains chat view from initial render. diff --git a/.changeset/jetbrains-shell-env-path.md b/.changeset/jetbrains-shell-env-path.md new file mode 100644 index 00000000000..8af2da43207 --- /dev/null +++ b/.changeset/jetbrains-shell-env-path.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix the JetBrains plugin finding shell-installed tools like bun and gh when launched from Finder or Dock. diff --git a/.changeset/kilo-memory-vscode.md b/.changeset/kilo-memory-vscode.md new file mode 100644 index 00000000000..6dc668d915d --- /dev/null +++ b/.changeset/kilo-memory-vscode.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Integrate project memory into the VS Code extension: memory status and controls in the Context settings tab, task-header and assistant-message affordances, the `/memory` prompt command, and Show/Toggle Project Memory command-palette entries. diff --git a/.changeset/multilingual-notebook-autocomplete.md b/.changeset/multilingual-notebook-autocomplete.md new file mode 100644 index 00000000000..1da4072fb4f --- /dev/null +++ b/.changeset/multilingual-notebook-autocomplete.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Enable autocomplete across supported languages in Jupyter notebooks. diff --git a/.changeset/prompt-mention-selection.md b/.changeset/prompt-mention-selection.md new file mode 100644 index 00000000000..f1a1c09c089 --- /dev/null +++ b/.changeset/prompt-mention-selection.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Allow Shift+Arrow selections in the prompt input to shrink back across file mentions. diff --git a/.changeset/reload-instance.md b/.changeset/reload-instance.md new file mode 100644 index 00000000000..010c58c61e0 --- /dev/null +++ b/.changeset/reload-instance.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Add a reload action that reboots the per-directory instance, picking up config, skills, agents, commands, and MCP prompts changed on disk. Sessions and history are preserved. Surfaces: `/reload` in the CLI palette and editor chat, a reload button in the task header and settings panel, the `Kilo Code: Reload Config and Skills` command, and a `POST /instance/reload` HTTP endpoint. The endpoint returns 409 while a session is actively running. diff --git a/.changeset/remote-tui-badge.md b/.changeset/remote-tui-badge.md new file mode 100644 index 00000000000..bb23dbb560a --- /dev/null +++ b/.changeset/remote-tui-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show the Remote badge in the TUI prompt status area when remote session relay is enabled. diff --git a/.changeset/routed-free-model-name.md b/.changeset/routed-free-model-name.md new file mode 100644 index 00000000000..31185ae44e5 --- /dev/null +++ b/.changeset/routed-free-model-name.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the model usage panel showing just "free" for auto-routed sessions. The routed model id (e.g. `tencent/hy3:free`) is now displayed correctly instead of being collapsed to its `:free` suffix. diff --git a/.changeset/sandbox-writable-paths-input-width.md b/.changeset/sandbox-writable-paths-input-width.md new file mode 100644 index 00000000000..b48de18dcb9 --- /dev/null +++ b/.changeset/sandbox-writable-paths-input-width.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Widen the Additional Writable Paths input in the Sandboxing settings so longer filesystem paths are easier to read while typing. diff --git a/.changeset/selected-organization-default.md b/.changeset/selected-organization-default.md new file mode 100644 index 00000000000..78a9cec07cd --- /dev/null +++ b/.changeset/selected-organization-default.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +--- + +Use cloud account preferences to select the active Kilo organization and hide unavailable personal accounts. diff --git a/.changeset/short-geckos-fry.md b/.changeset/short-geckos-fry.md new file mode 100644 index 00000000000..f7733130c82 --- /dev/null +++ b/.changeset/short-geckos-fry.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Generate commit messages in the user's selected UI language instead of always using English. diff --git a/.changeset/timeline-bar-jump-to-message.md b/.changeset/timeline-bar-jump-to-message.md new file mode 100644 index 00000000000..51dcb43a74a --- /dev/null +++ b/.changeset/timeline-bar-jump-to-message.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Click or press Enter/Space on a bar in the task timeline to jump the transcript to that message. diff --git a/.changeset/vscode-first-send-agent-scope.md b/.changeset/vscode-first-send-agent-scope.md new file mode 100644 index 00000000000..9cf8af712b6 --- /dev/null +++ b/.changeset/vscode-first-send-agent-scope.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the selected mode when sending the first message in a new VS Code task so the chosen model is paired with the correct agent instructions. diff --git a/.changeset/warm-speech-capture.md b/.changeset/warm-speech-capture.md new file mode 100644 index 00000000000..64d9be64cc6 --- /dev/null +++ b/.changeset/warm-speech-capture.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Wait for microphone capture to start before showing voice input as recording. diff --git a/.changeset/warn-leftover-opencode-config.md b/.changeset/warn-leftover-opencode-config.md new file mode 100644 index 00000000000..e723fba05d6 --- /dev/null +++ b/.changeset/warn-leftover-opencode-config.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show a dismissible notification when a leftover opencode config directory is found. Kilo no longer falls back to opencode configuration, so the notice points you to move `.opencode` config into a `.kilo` directory (or the global kilo config dir). Dismiss it once and it won't return unless the directory is still present. diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 228d05c91ef..74cdefde343 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -33,7 +33,10 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" + # Restoring and extracting the ~1 GB cache took 2m23s on Windows, while a # kilocode_change + # fresh install took 1m27s. Keep Windows off this cache until that reverses. # kilocode_change - name: Restore Bun dependencies + if: runner.os != 'Windows' # kilocode_change id: bun-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -84,16 +87,17 @@ runs: # Workaround for patched peer variants # e.g. ./patches/ for standard-openapi - # https://github.com/oven-sh/bun/issues/28147 + # https://github.com/oven-sh/bun/issues/28147 # kilocode_change if [ "$RUNNER_OS" = "Windows" ]; then - bun install --linker hoisted ${{ inputs.install-flags }} + bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }} # kilocode_change else - bun install ${{ inputs.install-flags }} + bun install --frozen-lockfile ${{ inputs.install-flags }} # kilocode_change fi shell: bash + # Do not upload a Windows cache that Windows jobs intentionally never restore. # kilocode_change - name: Save Bun dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' + if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' # kilocode_change uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index a5d7b51576f..ba43fb4124a 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -125,19 +125,11 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - name: Prepare CLI resources - working-directory: packages/kilo-jetbrains - run: bun script/build.ts --production --prepare-cli - env: - KILO_VERSION: ${{ steps.release.outputs.version }} - KILO_CHANNEL: ${{ steps.release.outputs.cli_channel }} - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - - name: Verify plugin working-directory: packages/kilo-jetbrains run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.release.outputs.version }} CHANNEL: ${{ steps.release.outputs.marketplace_channel }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 96c15f41d0b..2fa277d0901 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,7 +133,7 @@ jobs: if: matrix.settings.run uses: actions/cache@v5 # kilocode_change with: - path: node_modules/.cache/turbo + path: .turbo/cache # kilocode_change key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}- diff --git a/.kilo/skills/release-jetbrains/SKILL.md b/.kilo/skills/release-jetbrains/SKILL.md index c84718e439e..b34ec79c85e 100644 --- a/.kilo/skills/release-jetbrains/SKILL.md +++ b/.kilo/skills/release-jetbrains/SKILL.md @@ -34,7 +34,40 @@ Accepted specs: | `x.y.z-rc.n` | Explicit RC release. | | `x.y.z` | Explicit stable release. | -Show the resolved `version`, `kind`, and default `fromTagDefault` to the user and ask for confirmation before continuing. +Show the resolved `version`, `kind`, and default `fromTagDefault` to the user. + +## CLI Pin Verification + +Before dispatching prepare, verify the JetBrains plugin is pinned to the intended Kilo Core release. The plugin downloads the CLI version from `packages/kilo-jetbrains/package.json`, not from the JetBrains plugin version. + +Read the pinned CLI version: + +```bash +bun -e 'const p=require("./packages/kilo-jetbrains/package.json"); console.log(p.version)' +``` + +Verify the matching GitHub Release exists and includes every runtime asset the backend may download: + +```bash +cli_version="7.4.1" +gh release view "v${cli_version}" --repo Kilo-Org/kilocode --json assets \ + --jq '.assets[].name' | sort +``` + +Expected assets: + +```text +kilo-darwin-arm64.zip +kilo-darwin-x64.zip +kilo-linux-arm64.tar.gz +kilo-linux-x64.tar.gz +kilo-windows-arm64.zip +kilo-windows-x64.zip +``` + +If the pin is stale or the release assets are missing, stop and ask the user to update `packages/kilo-jetbrains/package.json` on `main` before dispatching prepare. The prepare workflow tags `origin/main`, so the pin must already be reviewed and merged before the release tag is created. + +Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, pinned CLI version, and CLI release asset status to the user, then ask for confirmation before continuing. ## Prepare Workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61ee8f2e57e..d78e6dd9e49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,10 +126,10 @@ Requires Java 21 (see [Prerequisites](#prerequisites)). From `packages/kilo-jetb ```bash ./gradlew typecheck # Compile-check all Kotlin sources ./gradlew test # Run all tests (backend + frontend) -./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox and prepare the CLI +./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox; backend downloads the pinned CLI ``` -Use `./gradlew runIde` only for a monolithic sandbox; prepare the CLI first with `bun run build --prepare-cli`. +Use `./gradlew runIde` only for a monolithic sandbox. JetBrains dev runs do not build or bundle CLI binaries; the backend downloads the pinned release at connect time. Or via the root turbo filter to run only JetBrains checks from the repo root: diff --git a/bun.lock b/bun.lock index a1f8b9ef20f..e0c5b1329c5 100644 --- a/bun.lock +++ b/bun.lock @@ -338,6 +338,7 @@ "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", + "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", diff --git a/nix/hashes.json b/nix/hashes.json index 6790015e90e..387012efc67 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-jTNTUFR9PuOqU3HVmA9tWR6E07O+LgQyubZOvQOxZ5I=", - "aarch64-linux": "sha256-afSECQ+kAsWirSpUAPe8sVcsal4Cu37MBWG/klc5I4Q=", - "aarch64-darwin": "sha256-z2LNeKVnKu95c6W2vl1x0qHTmZK9nwJlneurDibLjYw=", - "x86_64-darwin": "sha256-9dRFffouzJNfewnMm9Hm0FgIbYeUMBgv9mJUEwlSZS0=" + "x86_64-linux": "sha256-5/NgXEBhaNEEqIz9GVCXxQaLJwkqWXAAZOPoa6GyHTI=", + "aarch64-linux": "sha256-qt7PKn0SAhDCcYtXRY9+1TBp9x3Far7kK24jhUzoMf8=", + "aarch64-darwin": "sha256-3FN/i3chx01S52lAv0tjxVtRpUlPkIfInphpq+69uWA=", + "x86_64-darwin": "sha256-+rOacJLx+qOCRGW4F+AjvFcc9XUE2biB0BRSy3dTWGI=" } } diff --git a/packages/kilo-docs/AGENTS.md b/packages/kilo-docs/AGENTS.md index 5bab0319bdc..9d338f8e070 100644 --- a/packages/kilo-docs/AGENTS.md +++ b/packages/kilo-docs/AGENTS.md @@ -74,6 +74,10 @@ Use the Markdoc codicon tag format: ## Documentation Guidelines +### Style Guide + +Before writing documentation, review `packages/kilo-docs/STYLE_GUIDE.md` for voice, tone, and formatting conventions. + ### Adding New Pages 1. Create your page in the appropriate directory under `pages/` diff --git a/packages/kilo-docs/STYLE_GUIDE.md b/packages/kilo-docs/STYLE_GUIDE.md new file mode 100644 index 00000000000..205b36932af --- /dev/null +++ b/packages/kilo-docs/STYLE_GUIDE.md @@ -0,0 +1,205 @@ +--- +title: "Documentation Style Guide" +description: "Guidelines for writing Kilo Code documentation" +--- + +# Documentation Style Guide + +This guide covers writing, formatting, and structuring documentation for the Kilo Code docs site. + +## Voice and Tone + +Kilo Code documentation should be: + +- **Clear and direct** - Cut unnecessary words. Prefer active voice. +- **Helpful, not salesy** - Focus on what users can do, not just what's possible. +- **Consistent** - Use the same terminology and phrasing across pages. +- **Friendly but professional** - Write as a knowledgeable teammate explaining concepts. + +### Do + +- Write in the second person ("you") +- Use present tense +- Be specific: "Run `kilo run` to execute a task" not "You can run kilo run" + +### Don't + +- Use marketing fluff or hype language +- Write in passive voice when active is clearer +- Assume prior knowledge not explicitly stated + +## Headings + +- Use sentence case for heading text +- Start with the most important word +- One heading per section +- Use heading levels logically (don't skip from H2 to H4) + +```markdown +## Installing Kilo Code + +### VS Code Extension + +### CLI +``` + +## Procedures + +Use numbered lists for step-by-step instructions. Each step should be a complete action. + +```markdown +1. Open VS Code +2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X) +3. Search for "Kilo Code" +4. Click the dropdown arrow next to **Install** and select **Install Pre-Release Version** +``` + +### Procedural tips + +- Include keyboard shortcuts in parentheses +- Use present tense +- Start each step with a verb +- Don't number sub-steps; use nested lists instead + +## Callouts + +Use callouts to highlight important information. Choose the right type: + +| Type | Use for | +|---|---| +| `note` | General information users should know | +| `tip` | Helpful shortcuts or best practices | +| `info` | Context or background information | +| `warning` | Potential problems or important cautions | +| `danger` | Critical warnings that could cause data loss | +| `generic` | Content without a specific visual treatment | + +```markdown +{% callout type="tip" %} +**The easiest way to configure Kilo is to ask the agent.** Just tell the agent what you want. +{% /callout %} +``` + +## Cross-References + +- Use absolute paths starting from `/docs/` for internal links +- Don't include `.md` extensions +- Use descriptive link text, not "click here" + +```markdown +Good: [Quickstart Guide](/docs/getting-started/quickstart) + +Bad: [Click here](/docs/getting-started/quickstart) +``` + +## Code Examples + +- Use fenced code blocks with language specified +- Include comments in code where helpful +- Show realistic, working examples +- Use `kilo run` for CLI examples, not hypothetical commands + +````markdown +```bash +kilo run "create a utils.py file with a function that adds two numbers" +``` +```` + +### Code in prose + +Use backticks for inline code, file references, and commands: + +- `kilo.jsonc` for configuration files +- `Ctrl+Shift+X` for keyboard shortcuts +- `src/utils.ts` for file paths + +## Markdoc Conventions + +### Images + +Use the Markdoc image tag format: + +```markdown +{% image src="/docs/img/kilo-provider/connected-accounts.png" alt="Connect account screen" width="800" caption="Connect account screen" /%} +``` + +**Image path rules:** +- Always include `/docs` prefix +- Use generated screenshots from `packages/kilo-docs/public/img/screenshot-tests/` when available +- Write descriptive alt text for accessibility + +### Tables + +Use compact markdown tables without padding: + +```markdown +| Command | What it runs | +|---|---| +| `kilo serve` | The prod CLI on `$PATH`. | +``` + +### Tabs + +Use tabs for platform-specific content: + +```markdown +{% tabs %} +{% tab label="VS Code" %} + +Content for VS Code + +{% /tab %} +{% tab label="CLI" %} + +Content for CLI + +{% /tab %} +{% /tabs %} +``` + +### Mermaid Diagrams + +Use fenced `mermaid` blocks for architecture diagrams: + +````markdown +```mermaid +flowchart LR + A --> B +``` +```` + +## LLM-Generated Docs + +This documentation site is maintained with AI assistance. When reviewing or editing: + +- Verify technical accuracy manually +- Ensure examples actually work +- Check that terminology is consistent +- Don't accept generated content without review + +## Terminology + +Use consistent terms throughout: + +| Term | Use for | +|---|---| +| Kilo Code | The product name | +| kilo CLI | The command-line interface | +| VS Code extension | The VS Code extension | +| JetBrains plugin | The JetBrains IDE plugin | +| `kilo serve` | The local HTTP server | +| `kilo run` | The headless execution command | +| agent | The AI assistant | + +## Navigation + +- Add new pages to the appropriate nav file in `lib/nav/` +- Update `lib/nav/index.ts` to export the new nav section +- Navigation files are organized by section (e.g., `getting-started.ts`, `code-with-ai.ts`) + +## Documentation Lifecycle + +- Follow the branch naming convention: `docs/description-of-change` +- For documentation-only changes, create branches with the `docs/` prefix +- Update navigation when adding or removing pages +- Add redirects in `previous-docs-redirects.js` when moving or removing pages \ No newline at end of file diff --git a/packages/kilo-docs/lib/nav/automate.ts b/packages/kilo-docs/lib/nav/automate.ts index 1a106a87226..9a7a87ef527 100644 --- a/packages/kilo-docs/lib/nav/automate.ts +++ b/packages/kilo-docs/lib/nav/automate.ts @@ -10,7 +10,6 @@ export const AutomateNav: NavSection[] = [ href: "/automate/code-reviews/overview", children: "Code Reviews", subLinks: [ - { href: "/automate/code-reviews/overview", children: "Overview" }, { href: "/automate/code-reviews/github", children: "GitHub" }, { href: "/automate/code-reviews/gitlab", children: "GitLab" }, ], @@ -19,7 +18,6 @@ export const AutomateNav: NavSection[] = [ href: "/automate/agent-manager", children: "Agent Manager", subLinks: [ - { href: "/automate/agent-manager", children: "Reference" }, { href: "/automate/agent-manager-workflows", children: "Workflows" }, ], }, @@ -42,7 +40,6 @@ export const AutomateNav: NavSection[] = [ href: "/automate/mcp/overview", children: "MCP", subLinks: [ - { href: "/automate/mcp/overview", children: "MCP Overview" }, { href: "/automate/mcp/using-in-kilo-code", children: "Using MCP in Kilo Code", diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index 4b998fca3e6..c8a8c0a2238 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -30,6 +30,11 @@ export const CodeWithAiNav: NavSection[] = [ }, { href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" }, { href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" }, + ], + }, + { + title: "Features", + links: [ { href: "/code-with-ai/app-builder", children: "App Builder" }, { href: "/code-with-ai/gastown", @@ -80,7 +85,6 @@ export const CodeWithAiNav: NavSection[] = [ href: "/code-with-ai/agents/using-agents", children: "Agents", subLinks: [ - { href: "/code-with-ai/agents/using-agents", children: "Using Agents" }, { href: "/code-with-ai/agents/orchestrator-mode", children: "Orchestrator Mode", @@ -89,7 +93,6 @@ export const CodeWithAiNav: NavSection[] = [ }, ], }, - { title: "Productivity Tools", links: [ @@ -113,11 +116,7 @@ export const CodeWithAiNav: NavSection[] = [ href: "/code-with-ai/features/browser-use", children: "Agent Behavior", subLinks: [ - { href: "/code-with-ai/features/browser-use", children: "Browser Use" }, - { - href: "/code-with-ai/features/task-todo-list", - children: "Task Todo List", - }, + { href: "/code-with-ai/features/task-todo-list", children: "Task Todo List" }, { href: "/code-with-ai/features/checkpoints", children: "Checkpoints" }, { href: "/code-with-ai/features/file-encoding", children: "File Encoding" }, ], diff --git a/packages/kilo-docs/lib/nav/collaborate.ts b/packages/kilo-docs/lib/nav/collaborate.ts index a8ae1b34ca5..16514384c98 100644 --- a/packages/kilo-docs/lib/nav/collaborate.ts +++ b/packages/kilo-docs/lib/nav/collaborate.ts @@ -27,27 +27,26 @@ export const CollaborateNav: NavSection[] = [ }, { href: "/collaborate/teams/billing", children: "Billing" }, { href: "/collaborate/teams/analytics", children: "Analytics" }, + ], + }, + { + title: "AI Adoption Dashboard", + links: [ { href: "/collaborate/adoption-dashboard/overview", - children: "AI Adoption Dashboard", - subLinks: [ - { - href: "/collaborate/adoption-dashboard/overview", - children: "Overview", - }, - { - href: "/collaborate/adoption-dashboard/understanding-your-score", - children: "Understanding Your Score", - }, - { - href: "/collaborate/adoption-dashboard/improving-your-score", - children: "Improving Your Score", - }, - { - href: "/collaborate/adoption-dashboard/for-team-leads", - children: "For Team Leads", - }, - ], + children: "Overview", + }, + { + href: "/collaborate/adoption-dashboard/understanding-your-score", + children: "Understanding Your Score", + }, + { + href: "/collaborate/adoption-dashboard/improving-your-score", + children: "Improving Your Score", + }, + { + href: "/collaborate/adoption-dashboard/for-team-leads", + children: "For Team Leads", }, ], }, diff --git a/packages/kilo-docs/lib/nav/getting-started.ts b/packages/kilo-docs/lib/nav/getting-started.ts index 6da808b23a8..96b9f4f6d92 100644 --- a/packages/kilo-docs/lib/nav/getting-started.ts +++ b/packages/kilo-docs/lib/nav/getting-started.ts @@ -2,28 +2,22 @@ import { NavSection } from "../types" export const GettingStartedNav: NavSection[] = [ { - title: "Introduction", + title: "Get Started", links: [ { href: "/getting-started", children: "Overview" }, { href: "/getting-started/installing", children: "Installation" }, - { href: "/getting-started/quickstart", children: "Quickstart" }, + { href: "/getting-started/setup-authentication", children: "Authentication" }, + { href: "/getting-started/quickstart", children: "Your First Task" }, ], }, { title: "Configuration", links: [ - { - href: "/getting-started/setup-authentication", - children: "Setup & Authentication", - }, { href: "/getting-started/using-kilo-for-free", children: "Using Kilo for Free", }, - { - href: "/getting-started/byok", - children: "Bring Your Own Key (BYOK)", - }, + { href: "/getting-started/byok", children: "Bring Your Own Key (BYOK)" }, { href: "/ai-providers", children: "AI Providers" }, { href: "/getting-started/settings", @@ -34,7 +28,10 @@ export const GettingStartedNav: NavSection[] = [ ], }, { href: "/getting-started/adding-credits", children: "Adding Credits" }, - { href: "/getting-started/rate-limits-and-costs", children: "Cost Efficiency & Model Selection" }, + { + href: "/getting-started/rate-limits-and-costs", + children: "Cost Efficiency & Model Selection", + }, { href: "/getting-started/cost-controls-and-usage-safeguards", children: "Cost Controls and Usage Safeguards" }, ], }, diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts index d7bed669f1f..d880fcf6d94 100644 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ b/packages/kilo-docs/lib/nav/kiloclaw.ts @@ -12,7 +12,6 @@ export const KiloClawNav: NavSection[] = [ href: "/kiloclaw/control-ui/overview", children: "Control UI", subLinks: [ - { href: "/kiloclaw/control-ui/overview", children: "Overview" }, { href: "/kiloclaw/control-ui/changing-models", children: "Changing Models" }, { href: "/kiloclaw/control-ui/exec-approvals", children: "Exec Approvals" }, { href: "/kiloclaw/control-ui/version-pinning", children: "Version Pinning" }, @@ -22,7 +21,6 @@ export const KiloClawNav: NavSection[] = [ href: "/kiloclaw/chat-platforms", children: "Chat Platforms", subLinks: [ - { href: "/kiloclaw/chat-platforms", children: "Overview" }, { href: "/kiloclaw/chat-platforms/telegram", children: "Telegram" }, { href: "/kiloclaw/chat-platforms/discord", children: "Discord" }, { href: "/kiloclaw/chat-platforms/slack", children: "Slack" }, @@ -32,7 +30,6 @@ export const KiloClawNav: NavSection[] = [ href: "/kiloclaw/development-tools", children: "Integrations", subLinks: [ - { href: "/kiloclaw/development-tools", children: "Overview" }, { href: "/kiloclaw/development-tools/github", children: "GitHub" }, { href: "/kiloclaw/development-tools/google", children: "Google Workspace" }, { href: "/kiloclaw/development-tools/linear", children: "Linear" }, @@ -47,7 +44,6 @@ export const KiloClawNav: NavSection[] = [ href: "/kiloclaw/triggers", children: "Triggers", subLinks: [ - { href: "/kiloclaw/triggers", children: "Overview" }, { href: "/kiloclaw/triggers/webhooks", children: "Webhooks" }, { href: "/kiloclaw/triggers/scheduled", children: "Scheduled" }, ], diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index f190c92763e..446672c539b 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -39,7 +39,7 @@ The panel opens as an editor tab and stays active across focus changes. Agent Manager uses the same sign-in, provider settings, models, BYOK keys, custom providers, MCP servers, and permission rules as the extension sidebar. Configure them from extension Settings and they apply to Agent Manager as well. -See [Setup & Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details. +See [Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details. ## Working with Worktrees diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 103043b6443..d489dc63ebc 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -385,11 +385,15 @@ You can also set options that apply to all models from a provider: | Option | Type | Description | |---|---|---| -| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | +| `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) | | `baseURL` | `string` | Override the provider's base API URL | | `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | +{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} +`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config. +{% /callout %} + ## Filtering Available Models Control which models appear in the model picker for a provider using allowlists and blocklists: diff --git a/packages/kilo-docs/pages/code-with-ai/app-builder.md b/packages/kilo-docs/pages/code-with-ai/app-builder.md index c67d1c80bc5..ce9f87f0279 100644 --- a/packages/kilo-docs/pages/code-with-ai/app-builder.md +++ b/packages/kilo-docs/pages/code-with-ai/app-builder.md @@ -5,6 +5,10 @@ description: "Build complete applications with Kilo Code" # App Builder +{% callout type="warning" title="App Builder is deprecated" %} +App Builder is deprecated and no longer available to new users. Only existing users who previously used App Builder can still access it. +{% /callout %} + Kilo's **App Builder** lets you create end-to-end applications through natural language conversation. Describe what you want to build, watch it come to life in a real-time preview, and deploy directly from your Kilo dashboard. No local environment setup required. --- diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 9e68a71bddb..0f2993f560f 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -117,6 +117,7 @@ For detailed help on every command and subcommand, see the [CLI Command Referenc | `/status` | - | View status | | `/themes` | - | Switch theme | | `/help` | - | Show help | +| `/reload` | - | Reload config, skills, agents, and commands from disk | | `/editor` | - | Open external editor | | `/exit` | `/quit`, `/q` | Exit the app | @@ -338,6 +339,10 @@ The Kilo CLI is a fork of [OpenCode](https://opencode.ai) and supports the same Project-level configuration takes precedence over global settings. +{% callout type="warning" %} +**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`. +{% /callout %} + ### Key Configuration Options ```json @@ -464,6 +469,10 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab } ``` +{% callout type="warning" title="Only works in trusted config" %} +`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. +{% /callout %} + For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config). ## Interactive Mode diff --git a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md index bbfa42efc4d..cc537bd4c9c 100644 --- a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md +++ b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md @@ -131,10 +131,10 @@ JetBrains Kotlin toolchain is Java 21. Gradle commands report missing or incompa | Typecheck | `./gradlew typecheck` | | Tests | `./gradlew test` | | Full plugin build | `bun run build` | -| Gradle plugin assembly with prepared CLI binaries | `./gradlew buildPlugin` | +| Gradle plugin assembly with pinned CLI download | `./gradlew buildPlugin` | | Split-mode sandbox | `./gradlew --no-configuration-cache runIdeSplitMode` | | Split backend sandbox | `./gradlew --no-configuration-cache runIdeBackend` | -| Monolithic sandbox IDE | `./gradlew runIde` after `bun run build --prepare-cli` | +| Monolithic sandbox IDE | `./gradlew runIde` | Run `Plugin DevKit | Code | Frontend and Backend API Usage` inspection when moving code across split boundary. diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index ba63b3621df..7eafca68f86 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -232,7 +232,7 @@ Skills are discovered when a session starts. The CLI scans all configured skill - In the **CLI**: Skills are loaded when you start a new session or run `kilo run` - In the **VS Code extension**: Skills are loaded when the extension connects to the CLI server -Skills are re-scanned at the start of each new session. To pick up newly added or modified skills, start a new session. +Skills are re-scanned at the start of each new session. To pick up newly added or modified skills without starting a new session, use `/reload`. {% /tab %} {% tab label="CLI" %} @@ -242,7 +242,7 @@ Skills are discovered when a session starts. The CLI scans all configured skill - In the **CLI**: Skills are loaded when you start a new session or run `kilo run` - In the **VS Code extension**: Skills are loaded when the extension connects to the CLI server -Skills are re-scanned at the start of each new session. To pick up newly added or modified skills, start a new session. +Skills are re-scanned at the start of each new session. To pick up newly added or modified skills without starting a new session, use `/reload`. {% /tab %} {% /tabs %} @@ -430,7 +430,7 @@ The new platform does not have a marketplace UI yet. You can find and share skil 1. **Verify frontmatter**: Ensure `name` and `description` are present in the YAML frontmatter. The `name` does not need to match the directory name but should be unique across all loaded skills. -2. **Start a new session**: Skills are scanned at session start. Begin a new session to pick up changes. +2. **Reload or start a new session**: Use `/reload` to pick up changes without losing your current session, or start a new session. 3. **Check file location**: Ensure `SKILL.md` is directly inside the skill directory (e.g., `.kilo/skills/my-skill/SKILL.md`), not nested further. @@ -441,7 +441,7 @@ The new platform does not have a marketplace UI yet. You can find and share skil 1. **Verify frontmatter**: Ensure `name` and `description` are present in the YAML frontmatter. The `name` does not need to match the directory name but should be unique across all loaded skills. -2. **Start a new session**: Skills are scanned at session start. Begin a new session to pick up changes. +2. **Reload or start a new session**: Use `/reload` to pick up changes without losing your current session, or start a new session. 3. **Check file location**: Ensure `SKILL.md` is directly inside the skill directory (e.g., `.kilo/skills/my-skill/SKILL.md`), not nested further. diff --git a/packages/kilo-docs/pages/getting-started/index.md b/packages/kilo-docs/pages/getting-started/index.md index e3a0e8e89db..1fc06f14b63 100644 --- a/packages/kilo-docs/pages/getting-started/index.md +++ b/packages/kilo-docs/pages/getting-started/index.md @@ -28,7 +28,7 @@ Your sessions sync across all of these, so you can start a task on your phone an ## Quick Start 1. [Install Kilo Code](/docs/getting-started/installing) in your preferred environment -2. [Connect an AI provider](/docs/ai-providers) or use Kilo's built-in provider & credits +2. [Set up authentication](/docs/getting-started/setup-authentication) or use Kilo's built-in provider 3. [Run your first task](/docs/getting-started/quickstart) {% callout type="tip" %} diff --git a/packages/kilo-docs/pages/getting-started/installing.md b/packages/kilo-docs/pages/getting-started/installing.md index de0045e42a4..eafdecdb43a 100644 --- a/packages/kilo-docs/pages/getting-started/installing.md +++ b/packages/kilo-docs/pages/getting-started/installing.md @@ -124,11 +124,10 @@ If you plan to remain on that version for a while, you may also want to temporar ## Next Steps -After installation, check out these resources to get started: +After installation: -- [Quickstart Guide](/docs/getting-started/quickstart) - Get up and running in minutes -- [Setting Up Authentication](/docs/getting-started/setup-authentication) - Configure your AI provider -- [Your First Task](/docs/code-with-ai/agents/chat-interface) - Learn the basics of working with Kilo Code +1. **[Set up authentication](/docs/getting-started/setup-authentication)** to configure your AI provider +2. **[Run your first task](/docs/getting-started/quickstart)** — Learn how to chat with Kilo to complete tasks ## Getting Support diff --git a/packages/kilo-docs/pages/getting-started/quickstart.md b/packages/kilo-docs/pages/getting-started/quickstart.md index 77fbd4ef9d7..98354ee1520 100644 --- a/packages/kilo-docs/pages/getting-started/quickstart.md +++ b/packages/kilo-docs/pages/getting-started/quickstart.md @@ -1,14 +1,14 @@ --- -title: "Quickstart" +title: "Your First Task" description: "Get up and running with Kilo Code in minutes" --- -# Quickstart +# Your First Task After you [set up Kilo Code](/docs/getting-started/setup-authentication), follow the guide for your platform below. {% tabs %} -{% tab label="VSCode" %} +{% tab label="VS Code" %} ## Step by Step Guide @@ -36,16 +36,9 @@ Kilo Code analyzes your request and proposes actions. By default, most tools are To change which actions require approval, open **Settings** (gear icon) and go to the **Auto-Approve** tab. You can set each tool to Allow, Ask, or Deny. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for details. -### Step 5: Iterate +### Step 5: Iterate and Review -Kilo Code works iteratively. Continue giving feedback or follow-up instructions until your task is complete. - -### Key Differences from Legacy - -- **Settings** are managed via `kilo.jsonc` config files (the Settings webview reads and writes the same files) -- **Permissions** use a granular per-tool system instead of broad approval categories -- **Modes** are called "agents" and configured as `.md` files or via the `agent` config key -- **Autocomplete** uses FIM (Fill-in-the-Middle) with Codestral +Kilo Code works iteratively. Continue giving feedback or follow-up instructions until your task is complete. The assistant will propose file edits, run commands, and complete your request step by step. {% /tab %} {% tab label="CLI" %} @@ -68,7 +61,7 @@ Run the `kilo` command to start the interactive TUI (terminal user interface): kilo ``` -If this is your first time, run `kilo auth login` first to authenticate (see [Setup & Authentication](/docs/getting-started/setup-authentication)). +If this is your first time, run `kilo auth login` first to authenticate (see [Authentication](/docs/getting-started/setup-authentication)). ### Step 3: Type Your Task @@ -86,9 +79,9 @@ Kilo analyzes your request and proposes actions. By default, most tools are auto To change permission defaults, configure the `permission` key in your `kilo.jsonc` config file. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for details. -### Step 5: Iterate +### Step 5: Iterate and Review -Kilo works iteratively. Continue giving feedback or follow-up instructions until your task is complete. +Kilo works iteratively. Continue giving feedback or follow-up instructions until your task is complete. The assistant will propose file edits, run commands, and complete your request step by step. ### One-Shot Mode @@ -107,19 +100,13 @@ kilo run --auto "fix the failing tests in test/auth.test.ts" {% /tab %} {% /tabs %} -## Conclusion +## What You Can Do Next -You've completed your first task. Along the way you learned: +Now that you've completed your first task, try these capabilities: -- How to interact with Kilo Code using natural language -- Why approval keeps you in control -- How iteration lets the AI refine its work - -Ready for more? Here are some next steps: - -- **[Autocomplete](/docs/code-with-ai/features/autocomplete)** — Get inline code suggestions as you type -- **[Agents](/docs/code-with-ai/agents/using-agents)** — Explore different agents for different tasks -- **[Git commit generation](/docs/code-with-ai/features/git-commit-generation)** — Automatically generate commit messages +- **[Autocomplete](/docs/code-with-ai/features/autocomplete)** — Get inline code suggestions as you type in your editor +- **[Agents](/docs/code-with-ai/agents/using-agents)** — Switch between specialized agents for coding, architecture, debugging, and more +- **[Git](/docs/code-with-ai/features/git-commit-generation)** — Auto-generate commit messages from your changes {% callout type="tip" %} **Accelerate development:** Check out multiple copies of your repository and run Kilo Code on all of them in parallel (using git to resolve any conflicts, same as with human devs). This can dramatically speed up development on large projects. diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index 24e83c15c7e..56d69a1cf74 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -27,6 +27,12 @@ This is especially useful for complex configuration like custom model definition ## Managing Settings +Kilo reads JSONC config from a **global** location (`~/.config/kilo/kilo.jsonc`) and from your **project** (`kilo.jsonc`, or `.kilo/kilo.jsonc`). All clients — CLI, VS Code, and JetBrains — read the same files. + +{% callout type="warning" %} +**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`. +{% /callout %} + {% tabs %} {% tab label="VSCode" %} diff --git a/packages/kilo-docs/pages/getting-started/setup-authentication.md b/packages/kilo-docs/pages/getting-started/setup-authentication.md index 87521fd1a18..55946f5fedc 100644 --- a/packages/kilo-docs/pages/getting-started/setup-authentication.md +++ b/packages/kilo-docs/pages/getting-started/setup-authentication.md @@ -1,16 +1,16 @@ --- -title: "Setup & Authentication" +title: "Authentication" description: "Configure Kilo Code and connect to your AI providers" --- -# Setup & Authentication +# Authentication When you install Kilo Code, you'll be prompted to sign in or create a free account. This automatically configures everything you need to get started. ## Quick Start with Kilo Account {% tabs %} -{% tab label="VSCode" %} +{% tab label="VS Code" %} The extension prompts you to sign in when you first open Kilo Code in VS Code. Click **Sign In** and complete the browser-based flow. Sign-in applies across extension surfaces, including the sidebar and Agent Manager. @@ -93,7 +93,7 @@ Already have a ChatGPT subscription? You can use it with Kilo Code through the [ ### Configuring Your Provider {% tabs %} -{% tab label="VSCode" %} +{% tab label="VS Code" %} 1. Open Kilo Code in VS Code 2. Click the gear icon ({% codicon name="gear" /%}) in the extension UI to open **Settings** diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png new file mode 100644 index 00000000000..2b594d86ac1 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-variant-dropdown-1280-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f7edbc1bf6f10260626bf21406bb02e32dc17bf969c83e8b56251c50f755a1f +size 7737 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png new file mode 100644 index 00000000000..1ae814a6502 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-memory-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92cf06c01b19386f2041e378a08afdaf880fdc5c3e19132350fbda05684dbeec +size 5775 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/sandboxing-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/sandboxing-panel-chromium-linux.png index 8cde4b3acf0..6d462eb5f3c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/sandboxing-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/sandboxing-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a817c2b87e1d7b28a7c28deaa8fd8d1e9c51aa2daabda1a2de4a0db0a33fcc17 -size 27768 +oid sha256:ea36a9604fbc773d2312289f46205221e607a927b0d302b1d9a5e6bdc1d80308 +size 28888 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index 545366bac04..c8800620980 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413 -size 53851 +oid sha256:1ee158333ea6c4478f6c167eac878bdc5969fce47ba9f68a1e731fd9035e9e49 +size 52312 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index b64ddd110bb..04cb462161e 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -137,6 +137,8 @@ - +- + - - diff --git a/packages/kilo-gateway/src/api/profile.ts b/packages/kilo-gateway/src/api/profile.ts index aa717a64c43..0540af4eeff 100644 --- a/packages/kilo-gateway/src/api/profile.ts +++ b/packages/kilo-gateway/src/api/profile.ts @@ -25,6 +25,8 @@ export async function fetchProfile(token: string): Promise { email?: string name?: string organizations?: Organization[] + selectedOrganizationId?: string | null + hasPersonalAccount?: boolean | null } // Backend returns { user: { email, name, ... }, organizations } // Transform to flat KilocodeProfile structure @@ -32,6 +34,8 @@ export async function fetchProfile(token: string): Promise { email: data.user?.email ?? data.email ?? "", name: data.user?.name ?? data.name, organizations: data.organizations, + selectedOrganizationId: data.selectedOrganizationId ?? undefined, + hasPersonalAccount: data.hasPersonalAccount ?? undefined, } } @@ -40,6 +44,21 @@ export async function fetchProfile(token: string): Promise { */ export const getKiloProfile = fetchProfile +/** + * Resolve the organization a fresh login should default to. + * + * Applied once at login time (not on every profile fetch): prefer the cloud's + * validated `selectedOrganizationId`, and fall back to the first organization + * when the account has no personal account to sit on. Returns `undefined` to + * mean "personal account". + */ +export function defaultOrganizationId(profile: KilocodeProfile): string | undefined { + const orgs = profile.organizations ?? [] + const selected = profile.selectedOrganizationId + const valid = selected && orgs.some((org) => org.id === selected) ? selected : undefined + return valid ?? (profile.hasPersonalAccount === false ? orgs[0]?.id : undefined) +} + /** * Fetch user balance from Kilo API * @param token - Authentication token diff --git a/packages/kilo-gateway/src/auth/device-auth-tui.ts b/packages/kilo-gateway/src/auth/device-auth-tui.ts index 466aca7baaf..3e150b4f787 100644 --- a/packages/kilo-gateway/src/auth/device-auth-tui.ts +++ b/packages/kilo-gateway/src/auth/device-auth-tui.ts @@ -1,7 +1,7 @@ import { execFile } from "child_process" import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "../types.js" import { poll } from "./polling.js" -import { getKiloProfile, getKiloDefaultModel } from "../api/profile.js" +import { getKiloProfile, getKiloDefaultModel, defaultOrganizationId } from "../api/profile.js" import { KILO_API_BASE, POLL_INTERVAL_MS } from "../api/constants.js" import type { AuthOuathResult } from "@kilocode/plugin" @@ -127,13 +127,14 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record undefined) + const organizationId = profile ? defaultOrganizationId(profile) : undefined // Fetch default model - const model = await getKiloDefaultModel(token, organizationId) + await getKiloDefaultModel(token, organizationId) // Return success with OAuth credentials return { @@ -142,6 +143,7 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record { + const personal = hasPersonalAccount || organizations.length === 0 return [ - { - title: "Personal Account", - value: null, - description: !currentOrgId ? "→ (current)" : undefined, - category: "Accounts", - }, + ...(personal + ? [ + { + title: "Personal Account", + value: null, + description: !currentOrgId ? "→ (current)" : undefined, + category: "Accounts", + }, + ] + : []), ...organizations.map((org) => ({ title: org.name, value: org.id, diff --git a/packages/kilo-gateway/src/types.ts b/packages/kilo-gateway/src/types.ts index 801464966b7..3100b1e6304 100644 --- a/packages/kilo-gateway/src/types.ts +++ b/packages/kilo-gateway/src/types.ts @@ -27,6 +27,8 @@ export interface KilocodeProfile { email: string name?: string organizations?: Organization[] + selectedOrganizationId?: string + hasPersonalAccount?: boolean } export interface KilocodeBalance { diff --git a/packages/kilo-gateway/test/api/profile.test.ts b/packages/kilo-gateway/test/api/profile.test.ts new file mode 100644 index 00000000000..5ae6b2aaf5c --- /dev/null +++ b/packages/kilo-gateway/test/api/profile.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { defaultOrganizationId } from "../../src/api/profile.js" +import type { KilocodeProfile } from "../../src/types.js" + +const profile = (input: Partial = {}): KilocodeProfile => ({ + email: "user@example.com", + organizations: [{ id: "org_1", name: "Acme", role: "MEMBER" }], + ...input, +}) + +describe("defaultOrganizationId", () => { + test("defaults to the cloud selected organization", () => { + expect(defaultOrganizationId(profile({ selectedOrganizationId: "org_1" }))).toBe("org_1") + }) + + test("defaults to personal when there is no cloud selection", () => { + expect(defaultOrganizationId(profile())).toBeUndefined() + }) + + test("ignores a cloud selection that is not one of the user's organizations", () => { + expect(defaultOrganizationId(profile({ selectedOrganizationId: "missing" }))).toBeUndefined() + }) + + test("falls back to the first organization when there is no personal account", () => { + expect( + defaultOrganizationId( + profile({ + hasPersonalAccount: false, + organizations: [ + { id: "org_1", name: "Acme", role: "MEMBER" }, + { id: "org_2", name: "Beta", role: "MEMBER" }, + ], + }), + ), + ).toBe("org_1") + }) + + test("prefers a valid cloud selection over the first-organization fallback", () => { + expect( + defaultOrganizationId( + profile({ + selectedOrganizationId: "org_2", + hasPersonalAccount: false, + organizations: [ + { id: "org_1", name: "Acme", role: "MEMBER" }, + { id: "org_2", name: "Beta", role: "MEMBER" }, + ], + }), + ), + ).toBe("org_2") + }) +}) diff --git a/packages/kilo-jetbrains/.gitignore b/packages/kilo-jetbrains/.gitignore index e44cae7a20a..27ac8545f72 100644 --- a/packages/kilo-jetbrains/.gitignore +++ b/packages/kilo-jetbrains/.gitignore @@ -16,3 +16,8 @@ bin/ **/.kotlin/ **/bin/ !gradle/wrapper/gradle-wrapper.jar + +# Downloaded CLI artifacts +**/cli-cache/ +kilo-*.zip +kilo-*.tar.gz diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 5374daaa28b..323b158f6d7 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -14,7 +14,7 @@ - `plugin.xml` `` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`) - Service classes ↔ ``/`` entries in the corresponding module XML -- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list +- `packages/kilo-jetbrains/package.json` version ↔ GitHub CLI release tag consumed by the backend downloader ## IntelliJ Platform Source Lookup @@ -153,7 +153,9 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi ## CLI Integration -- CLI process spawning, extraction, and lifecycle belong in `backend`. +- CLI process spawning, download, extraction, and lifecycle belong in `backend`. +- The plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` for split-mode RPC and runtime use. +- The generated API client is produced from the pinned release binary by running `kilo generate` during the Gradle OpenAPI generation task. - For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`. - Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`. - Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`. @@ -187,22 +189,21 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi ## Build and Verification -- **Marketplace version build**: Use `script/build-version.sh ` from `packages/kilo-jetbrains/` to clean, prepare production CLI binaries, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed. +- **Marketplace version build**: Use `script/build-version.sh ` from `packages/kilo-jetbrains/` to clean, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed. - **Test version build**: If the user asks for a JetBrains test build, still require a version and use `script/build-version.sh --skip-signing --skip-verification` from `packages/kilo-jetbrains/` so no signing secrets are needed. Add `--skip-clean` only when the user wants a faster incremental test build. -- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries. -- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (prepares CLI binaries + runs Gradle `buildPlugin`). -- **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). +- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. It does not bundle per-platform CLI binaries. +- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (runs Gradle `buildPlugin`). +- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/`. - **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode. - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. -- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally and prepares the local-platform CLI binary automatically. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). -- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — prepares the local-platform CLI binary automatically; if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. -- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. +- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). +- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. +- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does not build or bundle CLI binaries; the backend downloads the pinned release at connect time. ### CLI/SDK Change Awareness -- JetBrains runtime behavior depends on the bundled CLI artifact under `backend/build/generated/cli/`; Gradle-only tasks and sandbox runs do not rebuild it. -- If there are relevant changes outside `packages/kilo-jetbrains/` in the CLI (`packages/opencode/`) or SDK/API generation paths (`packages/sdk/js/`, server endpoints, OpenAPI outputs), warn the user that the JetBrains run may be using stale generated CLI or SDK artifacts. -- Do not regenerate or rebuild those artifacts automatically just because such changes exist. Ask the user whether to refresh them first, typically with `bun run build --prepare-cli` from `packages/kilo-jetbrains/` for CLI artifacts and `./script/generate.ts` from the repo root for server/API SDK changes. +- JetBrains runtime behavior depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are not used unless published and pinned. +- If there are relevant server/API changes outside `packages/kilo-jetbrains/`, warn the user that JetBrains may need a newly published/pinned CLI release and regenerated SDK artifacts. ## UI Guidelines diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 082d91d37dc..77d9d224532 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -152,6 +152,43 @@ ## [Unreleased] +## [7.0.2] - 2026-07-07 + +### Added + +- First GA release of the native Kilo extension for JetBrains IDEs. +- Download the pinned Kilo Core release at runtime instead of bundling CLI binaries, keeping the JetBrains plugin smaller while verifying downloaded archives before use. +- Show Kilo Core runtime details from the JetBrains plugin so users can see which Core release is active. + +### Fixed + +- Improve JetBrains runtime CLI download reliability by pruning stale binaries, using the shell environment for PATH resolution, and surfacing exact release-resolution failures. + +### Changed + +- Polish JetBrains chat UI with auto-collapsing reasoning previews, clearer retry/offline footer state, and more balanced prompt, code, question, todo, history, and popup spacing. +- Show the active routed model name and remote status more consistently in CLI runtime surfaces. + +## [7.0.2-rc.2] - 2026-07-07 + +### Added +- Show compact previews for collapsed reasoning blocks so long assistant reasoning stays readable without taking over the transcript. +- Add clearer Kilo Core runtime information and diagnostics for release download failures. + +### Fixed +- Resolve the CLI executable using the user's shell environment so custom PATH setups work when sessions start from JetBrains. +- Keep retry and offline status visible in the session footer while preserving transcript context. +- Prevent oversized header popups by capping preview content. + +### Changed +- Download the required Kilo Core release at runtime and prune stale cached runtime binaries automatically. +- Polish JetBrains chat spacing, prompt input behavior, question/todo layout, history scrolling, code block padding, and session background colors. + +## [7.0.2-rc.1] - 2026-07-07 + +### Added +- Download the pinned Kilo Core release at runtime instead of bundling every CLI binary in the JetBrains plugin, keeping the Marketplace package smaller while still verifying downloaded artifacts. + ## [7.0.1] - 2026-07-06 ### Added diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 78978d66337..d35a08a8065 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -10,7 +10,7 @@ To try the v7 Early Access Program plugin, follow the [JetBrains EAP installatio ### Prerequisites -- **Bun** -- used to build CLI binaries and run build scripts +- **Bun** -- used to run package build scripts - **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK. Check with `java -version`. The preferred way to install is via [SDKMAN](https://sdkman.io/install): ```bash @@ -34,7 +34,7 @@ When working in a git worktree (e.g. via the Agent Manager), run `bun install` f bun install ``` -This installs Node dependencies required by the build scripts, including `script/build.ts` which prepares CLI binaries. +This installs Node dependencies required by the build scripts. --- @@ -52,7 +52,7 @@ From `packages/kilo-jetbrains/`: bun run build ``` -This builds the CLI binary for your current OS/arch only, copies it into the backend module resources, and runs `./gradlew buildPlugin`. The plugin archive is output to `build/distributions/`. +This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time. The plugin archive is output to `build/distributions/`. Or via Turbo from the repo root: @@ -70,7 +70,7 @@ From `packages/kilo-jetbrains/`: bun run build:production ``` -This builds CLI binaries for all 6 desktop platforms (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64, windows-arm64), copies them all into the backend jar, and fails if any are missing. Gradle also validates all platforms are present via `-Pproduction=true`. +This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time. The built plugin archive is at `build/distributions/kilo.jetbrains-.zip`. This zip can be installed in any JetBrains IDE via **Settings > Plugins > Install Plugin from Disk**. @@ -84,15 +84,15 @@ See [RELEASING.md](RELEASING.md) for the full release process, including how to ## Run the plugin -Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The task prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary. +Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The backend downloads the pinned CLI release on first connect. -Use `runIde` only when you need a monolithic sandboxed IntelliJ instance. `runIde` does not prepare the CLI binary automatically, so run `bun run build --prepare-cli` from `packages/kilo-jetbrains/` first to copy the local-platform binary into `backend/build/generated/cli/cli/`. +Use `runIde` only when you need a monolithic sandboxed IntelliJ instance. -Production packaging still requires running `bun run build:production` so all platform binaries are present. +Production packaging uses `bun run build:production` and still downloads the host CLI at runtime. ### Run the split backend -Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache 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. +Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session. 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. @@ -107,7 +107,6 @@ All properties below are passed with `-P` on the Gradle command line or in the r | `kilo.splitModeServerPort` | `0` | Backend split-mode server port. `0` or omitted lets the IntelliJ Platform Gradle Plugin pick a free port when the task runs. | | `kilo.dev.storage.isolated` | `false` | When `true`, CLI runs with `XDG_*_HOME` pointing to `.kilo-dev/` in the worktree root, fully isolating dev storage from your real Kilo installation. Enabled by default in the checked-in split-mode run configurations. | | `kilo.dev.worktree.root` | monorepo root | Worktree root used to resolve `.kilo-dev/`. Auto-detected from the Gradle project directory; override only when the auto-detection is wrong. | -| `kilo.bun.path` | `bun` on `$PATH` | Absolute path to Bun. Set this when IntelliJ-launched Gradle cannot find Bun automatically. | The checked-in IDE run configurations pass `--no-configuration-cache` because the IntelliJ Platform Gradle Plugin run-IDE tasks are not configuration-cache compatible in this setup. They also pass `--purge-old-log-directories` so stale sandbox logs do not hide the current backend and frontend `kilo-dev.log` files. @@ -195,9 +194,7 @@ For direct local packaging, run: bun run build ``` -This prepares the local CLI binary and then runs `./gradlew buildPlugin`. - -If you run `./gradlew buildPlugin` directly, Gradle verifies CLI binaries are present but does not build them first. Run `bun run build --prepare-cli` beforehand if the binaries are missing. +This runs `./gradlew buildPlugin`. For production verification: diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md index 47d65e0f5ce..a9e844b86aa 100644 --- a/packages/kilo-jetbrains/RELEASING.md +++ b/packages/kilo-jetbrains/RELEASING.md @@ -8,6 +8,8 @@ The published code comes from `jetbrains/v`. Marketplace and GitHub rel Maintainers can use the Kilo `release-jetbrains` skill to drive this process from a version request such as `next rc` or an explicit version. The skill resolves and confirms the version, dispatches and watches the prepare workflow, helps produce a filtered human-readable JetBrains/CLI changelog draft, commits the reviewed changelog to the release PR, and watches publishing after the PR is merged. +JetBrains plugin builds and runtime downloads use the Kilo Core version pinned in `packages/kilo-jetbrains/package.json`, so verify that pin points at a published `v` release before creating the release tag. + The skill lives at `.kilo/skills/release-jetbrains/SKILL.md`. It does not move or recreate release tags, and merge permission is only required if the user explicitly asks the skill to merge the release PR automatically. ## Create Release Tag And PR diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 8139adcb753..7421a38b88e 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -1,4 +1,5 @@ import normalization.NormalizeOpenApiSpecTask +import org.gradle.api.tasks.WriteProperties plugins { alias(libs.plugins.rpc) @@ -15,18 +16,35 @@ kotlin { val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin") val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.json") val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json") +val generatedProps = layout.buildDirectory.dir("generated/kilo-props") + +val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> + Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) + ?: error("Could not read version from package.json") +} sourceSets { main { - resources.srcDir(layout.buildDirectory.dir("generated/cli")) + resources.srcDir(generatedProps) kotlin.srcDir(generatedApi) } } +val writeKiloProperties by tasks.registering(WriteProperties::class) { + description = "Write pinned Kilo CLI properties" + val out = generatedProps.map { it.file("kilo.properties") } + destinationFile.set(out) + property("cli.version", pinnedCliVersion) +} + val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { description = "Generate CLI OpenAPI spec into the build directory" - opencodeDir.set(rootProject.layout.projectDirectory.dir("../opencode")) - serverSrcDir.set(rootProject.layout.projectDirectory.dir("../opencode/src/server")) + cliVersion.set(pinnedCliVersion) + token.set( + providers.environmentVariable("GH_TOKEN") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + ) + cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) } @@ -83,49 +101,19 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { } tasks.named("compileKotlin") { - dependsOn(fixGeneratedApi) + dependsOn(fixGeneratedApi, writeKiloProperties) inputs.dir(generatedApi) } +tasks.named("processResources") { + dependsOn(writeKiloProperties) +} + tasks.named("compileTestKotlin") { dependsOn(fixGeneratedApi) inputs.dir(generatedApi) } -val cliDir = layout.buildDirectory.dir("generated/cli/cli") -val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false) - -val prepareLocalCli by tasks.registering(PrepareLocalCliTask::class) { - description = "Prepare the local-platform CLI binary for JetBrains backend runs" - root.set(rootProject.layout.projectDirectory) - dir.set(cliDir) - bunPath.convention( - providers.gradleProperty("kilo.bun.path") - .orElse(providers.environmentVariable("BUN_EXE")) - ) -} - -val requiredPlatforms = listOf( - "darwin-arm64", - "darwin-x64", - "linux-arm64", - "linux-x64", - "windows-x64", - "windows-arm64", -) - -val prod = production -val checkCli by tasks.registering(CheckCliTask::class) { - description = "Verify CLI binaries exist before packaging" - dir.set(cliDir) - this.production.set(prod) - platforms.set(requiredPlatforms) -} - -// CLI binaries are verified only at packaging time (buildPlugin), not at -// processResources time, so that Kotlin compile and tests work without binaries. -// Wire checkCli to buildPlugin in the root build.gradle.kts instead. - dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) @@ -137,6 +125,7 @@ dependencies { implementation(project(":shared")) implementation(libs.okhttp) implementation(libs.okhttp.sse) + implementation(libs.commons.compress) implementation(libs.kotlinx.serialization.json) testImplementation(libs.okhttp.mockwebserver) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index d6e1c67c088..275fca284ae 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.ConfigDto */ sealed class KiloAppState { data object Disconnected : KiloAppState() + data class Downloading(val percent: Int, val version: String, val platform: String) : KiloAppState() data object Connecting : KiloAppState() data class Loading(val progress: LoadProgress) : KiloAppState() data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState() 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 e692df9ca68..c815f95444e 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 @@ -97,7 +97,7 @@ class KiloBackendAppService private constructor( private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L private const val APP_LOAD_TIMEOUT_MS = 30_000L - private const val READY_TIMEOUT_MS = 5_000L + private const val READY_TIMEOUT_MS = 120_000L /** Test factory — no IntelliJ deps needed. */ internal fun create( @@ -146,7 +146,7 @@ class KiloBackendAppService private constructor( suspend fun connect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return + if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return ensureWatcher() connection.connect() } @@ -179,6 +179,7 @@ class KiloBackendAppService private constructor( ensureWatcher() connection.connect() } + is KiloAppState.Downloading, KiloAppState.Connecting, is KiloAppState.Loading -> Unit is KiloAppState.MigrationRequired -> { @@ -231,10 +232,11 @@ class KiloBackendAppService private constructor( when (_appState.value) { is KiloAppState.Ready -> return is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required") + is KiloAppState.Downloading, is KiloAppState.Loading, KiloAppState.Connecting -> { val state = withTimeoutOrNull(timeoutMs) { - appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting } + appState.first { it !is KiloAppState.Downloading && it !is KiloAppState.Loading && it !is KiloAppState.Connecting } } when (state) { is KiloAppState.Ready -> return @@ -292,7 +294,7 @@ class KiloBackendAppService private constructor( private suspend fun reconnect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { + if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { log.info("reconnect: already ${current::class.simpleName} — skipping") return } @@ -307,6 +309,7 @@ class KiloBackendAppService private constructor( connection.state.collect { next -> when (next) { ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected + is ConnectionState.Downloading -> _appState.value = KiloAppState.Downloading(next.percent, next.version, next.platform) ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> { load() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index aa4300c4c42..bce451fc1e1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -1,8 +1,8 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendHttpClients import ai.kilocode.backend.cli.KiloCliDataParser -import ai.kilocode.backend.cli.CliServer import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi @@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicReference sealed class ConnectionState { data object Disconnected : ConnectionState() + data class Downloading(val percent: Int, val version: String, val platform: String) : ConnectionState() data object Connecting : ConnectionState() data class Connected(val port: Int, val password: String) : ConnectionState() data class Error(val message: String, val details: String? = null) : ConnectionState() @@ -144,7 +145,7 @@ class KiloConnectionService( } /** - * Kill the CLI process, re-extract the binary from JAR, and restart. + * Kill the CLI process, re-download the binary, and restart. * * Called under [KiloBackendAppService]'s mutex. */ @@ -153,7 +154,7 @@ class KiloConnectionService( teardown() log.info("reinstall: teardown complete — setting forceExtract flag") server.forceExtract = true - log.info("reinstall: spawning new CLI process (binary will be re-extracted)") + log.info("reinstall: spawning new CLI process (binary will be re-downloaded)") open() log.info("reinstall: open() returned — CLI process started with fresh binary") } @@ -190,7 +191,10 @@ class KiloConnectionService( setState(ConnectionState.Connecting) - val result = server.init() + val result = server.init( + onProgress = { item -> setState(ConnectionState.Downloading(item.percent, item.version, item.platform)) }, + onResolved = { setState(ConnectionState.Connecting) }, + ) if (result is CliServer.State.Error) { setState(ConnectionState.Error(result.message, result.details)) @@ -198,6 +202,7 @@ class KiloConnectionService( } val ready = result as CliServer.State.Ready + setState(ConnectionState.Connecting) port = ready.port password = ready.password diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index e124accafc4..9df08f47234 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -271,8 +271,8 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created.toDouble(), - updated = s.time.updated.toDouble(), + created = time(s.id, "created", s.time.created), + updated = time(s.id, "updated", s.time.updated), archived = s.time.archived, ), summary = s.summary?.let { @@ -292,15 +292,15 @@ class KiloBackendSessionManager( title = s.title, version = s.version, time = SessionTimeDto( - created = s.time.created.toDouble(), - updated = s.time.updated.toDouble(), + created = time(s.id, "created", s.time.created), + updated = time(s.id, "updated", s.time.updated), archived = s.time.archived, ), summary = s.summary?.let { SessionSummaryDto( - additions = it.additions.safeInt(), - deletions = it.deletions.safeInt(), - files = it.files.safeInt(), + additions = it.additions?.safeInt() ?: 0, + deletions = it.deletions?.safeInt() ?: 0, + files = it.files?.safeInt() ?: 0, ) }, ) @@ -315,6 +315,12 @@ class KiloBackendSessionManager( private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) + private fun time(id: String, field: String, value: Number?): Double { + if (value != null) return value.toDouble() + log.warn("Session $id missing $field timestamp; defaulting to 0.0") + return 0.0 + } + private fun escape(value: String) = buildString { for (c in value) { when (c) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt index f36f917acb9..f0418d926a8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt @@ -13,8 +13,14 @@ interface CliServer { var forceExtract: Boolean fun process(): Process? - suspend fun init(): State + suspend fun init(onProgress: (CliDownload) -> Unit = {}, onResolved: () -> Unit = {}): State fun exited(proc: Process) fun stop() fun dispose() } + +data class CliDownload( + val percent: Int, + val version: String, + val platform: String, +) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index a8cfdb07a35..301142da9f7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -5,8 +5,7 @@ import ai.kilocode.backend.dev.KiloDevMode import ai.kilocode.log.KiloLog import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager -import com.intellij.openapi.util.SystemInfo -import com.intellij.util.system.CpuArch +import com.intellij.util.EnvironmentUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -20,7 +19,7 @@ import java.util.concurrent.TimeUnit /** * Manages the Kilo CLI binary lifecycle. * - * Extracts the bundled CLI from JAR resources into IntelliJ's system directory, + * Downloads the pinned CLI into IntelliJ's system directory, * spawns `kilo serve --port 0`, and exposes the result as [State]. * * Concurrency is handled by the owning [KiloBackendAppService] — all public @@ -50,9 +49,10 @@ class KiloBackendCliManager( override fun process(): Process? = process - override suspend fun init(): CliServer.State { + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { return try { - val path = extractCli() + val path = resolveCli(onProgress) + onResolved() log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)") withTimeout(STARTUP_TIMEOUT_MS) { spawn(path) @@ -84,55 +84,15 @@ class KiloBackendCliManager( cleanup(proc, "stop()") } - private fun extractCli(): File { - val platform = platform() - val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo" - val target = File(PathManager.getSystemPath(), "kilo/bin/$exe") - val worker = File(target.parentFile, "kilo-sandbox-mutation-worker.js") - - if (forceExtract) { - log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}") - if (target.exists()) target.delete() - if (worker.exists()) worker.delete() - forceExtract = false - } - - extractResource("cli/$platform/$exe", target, executable = true) - if (worker.exists()) worker.delete() - extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false) - return target - } - - private fun extractResource(resource: String, target: File, executable: Boolean) { - val loader = javaClass.classLoader - val url = loader.getResource(resource) - ?: throw IllegalStateException("CLI resource not found in JAR resources at $resource") - - val size = url.openConnection().contentLengthLong - if (size >= 0 && target.exists() && target.length() == size) { - log.info("CLI resource up-to-date at ${target.absolutePath}") - if (executable && !SystemInfo.isWindows) { - target.setExecutable(true) - } - return - } - - log.info("Extracting CLI resource to ${target.absolutePath}") - target.parentFile.mkdirs() - - url.openStream().use { input -> - target.outputStream().use { output -> - input.copyTo(output) - } - } - - if (executable && !SystemInfo.isWindows) { - target.setExecutable(true) - } + private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File { + val force = forceExtract + forceExtract = false + if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}") + return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress) } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). - internal fun buildEnv(pwd: String, base: Map = System.getenv()): Map = + internal fun buildEnv(pwd: String, base: Map = EnvironmentUtil.getEnvironmentMap()): Map = buildKiloCliEnv(pwd, base, log) private suspend fun spawn(cli: File): CliServer.State = @@ -267,21 +227,6 @@ class KiloBackendCliManager( runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") } } - private fun platform(): String { - val os = when { - SystemInfo.isMac -> "darwin" - SystemInfo.isLinux -> "linux" - SystemInfo.isWindows -> "windows" - else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}") - } - val arch = when (CpuArch.CURRENT) { - CpuArch.ARM64 -> "arm64" - CpuArch.X86_64 -> "x64" - else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}") - } - return "$os-$arch" - } - private fun generatePassword(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) @@ -294,7 +239,7 @@ private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}} // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). internal fun buildKiloCliEnv( pwd: String, - base: Map = System.getenv(), + base: Map = EnvironmentUtil.getEnvironmentMap(), log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java), ): Map = buildMap { putAll(base) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt new file mode 100644 index 00000000000..181d114efc7 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -0,0 +1,321 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.log.KiloLog +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.util.SystemInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import java.io.File +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.zip.ZipInputStream +import kotlin.math.roundToInt + +class KiloCliDownloader( + private val http: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .build(), + private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java), + private val root: File = File(PathManager.getSystemPath(), "kilo/cli"), + private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download", + private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags", +) { + companion object { + private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") + private val JSON = Json { ignoreUnknownKeys = true } + private val LOCKS = ConcurrentHashMap() + } + + suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File = + withContext(Dispatchers.IO) { + locked { + val platform = KiloCliPlatform.current() + val dir = File(File(root, version), platform) + val exe = File(dir, "bin/${KiloCliPlatform.exe()}") + val done = File(dir, ".complete") + val ext = KiloCliPlatform.archive(platform) + + if (!force) { + cached(version, platform, exe, done)?.let { return@locked it } + } + + val digest = asset(version, platform, ext) + val stage = stage(version, platform) + try { + val archive = File(stage, "kilo-$platform.$ext") + val staged = File(stage, "bin/${KiloCliPlatform.exe()}") + val complete = File(stage, ".complete") + + log.info( + "Kilo CLI $version for $platform is not cached; downloading new release into ${stage.absolutePath}" + ) + onProgress(CliDownload(0, version, platform)) + download(version, platform, ext, archive, onProgress) + verify(archive, digest) + log.info( + "Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)" + ) + extract(archive, stage) + if (!staged.isFile) { + throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}") + } + if (!SystemInfo.isWindows) staged.setExecutable(true) + if (archive.exists() && !archive.delete()) { + log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}") + } + complete.writeText("$digest\n") + replace(dir, stage) + onProgress(CliDownload(100, version, platform)) + prune(version) + exe + } finally { + if (stage.exists() && !stage.deleteRecursively()) { + log.warn("Failed to delete staged Kilo CLI download ${stage.absolutePath}") + } + } + } + } + + private fun cached(version: String, platform: String, exe: File, done: File): File? { + val digest = done.takeIf { it.isFile }?.readText()?.trim() + if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null + log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") + if (!SystemInfo.isWindows) exe.setExecutable(true) + prune(version) + return exe + } + + private fun locked(block: () -> T): T { + if (!root.isDirectory && !root.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}") + } + val file = File(root, ".lock").canonicalFile + val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() } + return synchronized(mutex) { + RandomAccessFile(file, "rw").channel.use { channel -> + channel.lock().use { block() } + } + } + } + + private fun stage(version: String, platform: String): File { + val tmp = File(root, ".tmp") + val dir = File(tmp, "$version-$platform-${System.nanoTime()}") + if (!dir.isDirectory && !dir.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}") + } + return dir + } + + private fun replace(dir: File, stage: File) { + val parent = dir.parentFile + if (!parent.isDirectory && !parent.mkdirs()) { + throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}") + } + + val backup = File(parent, ".${dir.name}.backup-${System.nanoTime()}") + if (dir.exists() && !dir.renameTo(backup)) { + throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside") + } + if (stage.renameTo(dir)) { + if (backup.exists() && !backup.deleteRecursively()) { + log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}") + } + return + } + + if (backup.exists() && !backup.renameTo(dir)) { + log.warn("Failed to restore previous Kilo CLI cache ${backup.absolutePath} to ${dir.absolutePath}") + } + throw IllegalStateException("Failed to install Kilo CLI cache ${stage.absolutePath} to ${dir.absolutePath}") + } + + private fun fail(message: String): Nothing { + log.warn(message) + throw IllegalStateException(message) + } + + private fun asset(version: String, platform: String, ext: String): String { + val name = "kilo-$platform.$ext" + val url = "${api.trimEnd('/')}/v$version" + log.info("Fetching Kilo CLI release metadata for $version from $url") + val request = Request.Builder() + .url(url) + .header("Accept", "application/vnd.github+json") + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val info = rate(response) + val body = runCatching { response.body?.string() }.getOrNull()?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(response)) { + log.warn("GitHub API rate limit hit fetching Kilo CLI $version metadata from $url ($info)$detail") + throw IllegalStateException( + "GitHub API rate limit exceeded while resolving Kilo CLI $version ($info)$detail" + ) + } + log.warn("Failed to fetch Kilo CLI $version metadata from $url: HTTP ${response.code} ($info)$detail") + throw IllegalStateException( + "Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code} ($info)$detail" + ) + } + log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" } + val body = response.body?.string() + ?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body") + val assets = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + val entry = assets?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } + if (entry == null) { + val names = assets?.mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull }?.joinToString(", ") + fail("Kilo CLI release $version has no asset named $name (available assets: ${names ?: "none"})") + } + val digest = entry.jsonObject["digest"]?.jsonPrimitive?.contentOrNull + if (digest == null) { + fail("Kilo CLI release $version asset $name has no digest yet; GitHub has not published a SHA-256 checksum for it") + } + if (!digest.matches(DIGEST)) { + fail("Kilo CLI release $version asset $name has a malformed digest '$digest'; expected sha256:<64 hex chars>") + } + return digest + } + } + + private fun rate(response: Response): String { + val reset = response.header("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${response.header("X-RateLimit-Limit")} remaining=${response.header("X-RateLimit-Remaining")} " + + "used=${response.header("X-RateLimit-Used")} reset=$reset retryAfter=${response.header("Retry-After")}" + } + + private fun limited(response: Response) = + response.code == 429 || (response.code == 403 && response.header("X-RateLimit-Remaining") == "0") + + private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) { + val url = url(version, platform, ext) + log.info("Downloading Kilo CLI $version for $platform from $url") + val request = Request.Builder().url(url).build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Failed to download Kilo CLI $version for $platform: HTTP ${response.code}") + } + val body = response.body ?: throw IllegalStateException("Failed to download Kilo CLI $version: empty response body") + val total = body.contentLength() + var read = 0L + var last = 0 + file.outputStream().use { output -> + body.byteStream().use { input -> + val buffer = ByteArray(8 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + output.write(buffer, 0, n) + read += n + if (total > 0) { + val pct = ((read.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100) + if (pct != last) { + last = pct + onProgress(CliDownload(pct, version, platform)) + } + } + } + } + } + } + } + + private fun verify(file: File, digest: String) { + val actual = sum(file) + if (actual == digest) return + if (file.exists() && !file.delete()) log.warn("Failed to delete invalid Kilo CLI archive ${file.absolutePath}") + throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual") + } + + private fun sum(file: File) = "sha256:${sha256(file)}" + + private fun sha256(file: File): String { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val n = input.read(buffer) + if (n < 0) break + md.update(buffer, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + + private fun extract(file: File, dir: File) { + log.info("Extracting Kilo CLI archive ${file.absolutePath}") + if (file.name.endsWith(".zip")) { + ZipInputStream(file.inputStream().buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) } + zip.closeEntry() + } + } + return + } + + TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar -> + while (true) { + val entry = tar.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) } + } + } + } + + private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) { + val path = if (name.startsWith("bin/")) name else "bin/$name" + val target = File(dir, path).canonicalFile + val base = dir.canonicalFile + if (target != base && !target.path.startsWith(base.path + File.separator)) { + throw IllegalStateException("Archive entry escapes target directory: $name") + } + if (directory) { + target.mkdirs() + return + } + target.parentFile.mkdirs() + target.outputStream().use(copy) + if (!SystemInfo.isWindows && (target.name == "kilo" || target.name == "bwrap")) { + target.setExecutable(true) + } + } + + /** + * Delete every cached CLI version under [root] except [keep]. Each plugin update pins a new + * CLI version, so without this old versions would accumulate in the IDE system directory + * indefinitely. Runs only after the active version resolved successfully so a failed download + * never wipes the last working copy. + */ + private fun prune(keep: String) { + val entries = root.listFiles() ?: return + for (entry in entries) { + if (!entry.isDirectory || entry.name == keep || entry.name.startsWith(".")) continue + log.info("Removing stale Kilo CLI version ${entry.absolutePath}") + if (!entry.deleteRecursively()) { + log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}") + } + } + } + + private fun url(version: String, platform: String, ext: String) = + "${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext" +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt new file mode 100644 index 00000000000..86b494da6a9 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt @@ -0,0 +1,26 @@ +package ai.kilocode.backend.cli + +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.system.CpuArch + +internal object KiloCliPlatform { + fun current(): String { + val os = when { + SystemInfo.isMac -> "darwin" + SystemInfo.isLinux -> "linux" + SystemInfo.isWindows -> "windows" + else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}") + } + val arch = when (CpuArch.CURRENT) { + CpuArch.ARM64 -> "arm64" + CpuArch.X86_64 -> "x64" + else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}") + } + return "$os-$arch" + } + + fun exe(): String = if (SystemInfo.isWindows) "kilo.exe" else "kilo" + + fun archive(platform: String = current()): String = + if (platform.startsWith("linux-")) "tar.gz" else "zip" +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt new file mode 100644 index 00000000000..888a18f6ce4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloProps.kt @@ -0,0 +1,16 @@ +package ai.kilocode.backend.cli + +import java.util.Properties + +object KiloProps { + private val props by lazy { + val stream = KiloProps::class.java.classLoader.getResourceAsStream("kilo.properties") + ?: throw IllegalStateException("kilo.properties resource not found") + stream.use { + Properties().apply { load(it) } + } + } + + fun cliVersion(): String = props.getProperty("cli.version") + ?: throw IllegalStateException("cli.version missing from kilo.properties") +} 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 b593cb5dbaf..58e929dcd7e 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 @@ -9,6 +9,8 @@ import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult +import ai.kilocode.backend.cli.KiloCliPlatform +import ai.kilocode.backend.cli.KiloProps import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi @@ -51,6 +53,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun health(): HealthDto = app.health() + override suspend fun cliVersion(): String = KiloProps.cliVersion() + + override suspend fun cliPlatform(): String = KiloCliPlatform.current() + override suspend fun retry() = app.retry() override suspend fun restart() = app.restart() @@ -109,6 +115,12 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { internal fun appStateDto(state: KiloAppState): KiloAppStateDto = when (state) { KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + is KiloAppState.Downloading -> KiloAppStateDto( + status = KiloAppStatusDto.DOWNLOADING, + downloadPercent = state.percent, + downloadVersion = state.version, + downloadPlatform = state.platform, + ) KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING) is KiloAppState.Loading -> KiloAppStateDto( status = KiloAppStatusDto.LOADING, @@ -143,12 +155,19 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto( 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) }, + // The pinned CLI release does not expose hasPersonalAccount yet, so default to + // showing the personal account. Flip back to p.profile.hasPersonalAccount once a + // CLI release ships the field. + hasPersonalAccount = true, + balance = p.balance?.balance?.let { ProfileBalanceDto(balance = it) }, kiloPass = p.kiloPass?.let { + val base = it.currentPeriodBaseCreditsUsd ?: return@let null + val usage = it.currentPeriodUsageUsd ?: return@let null + val bonus = it.currentPeriodBonusCreditsUsd ?: return@let null ProfileKiloPassDto( - currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd, - currentPeriodUsageUsd = it.currentPeriodUsageUsd, - currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd, + currentPeriodBaseCreditsUsd = base, + currentPeriodUsageUsd = usage, + currentPeriodBonusCreditsUsd = bonus, nextBillingAt = it.nextBillingAt, ) }, diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt index 25bb6779ea5..b7c4f73f4c0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt @@ -39,6 +39,7 @@ class KiloAppStateTest { @Test fun `KiloAppState sealed subtypes are distinct`() { assertIs(KiloAppState.Disconnected) + assertIs(KiloAppState.Downloading(42, "1.2.3", "darwin-arm64")) assertIs(KiloAppState.Connecting) assertIs(KiloAppState.Loading(LoadProgress())) assertIs(KiloAppState.Error("fail")) 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 7ba64fec23a..d99c80fb556 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 @@ -3,18 +3,21 @@ package ai.kilocode.backend.app import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.CliDownload import ai.kilocode.backend.rpc.appStateDto import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull @@ -67,7 +70,8 @@ class KiloBackendAppServiceTest { override fun process(): Process? = null - override suspend fun init(): CliServer.State { + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onResolved() if (starts.getAndIncrement() == 0) { val socket = ServerSocket(0) srv = socket @@ -102,6 +106,46 @@ class KiloBackendAppServiceTest { assertNotNull(ready.data.notifications) } + @Test + fun `download progress maps to app state before ready`() = runBlocking { + val resolved = CompletableDeferred() + val signal = CompletableDeferred() + val server = object : CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onProgress(CliDownload(37, "1.2.3", "darwin-arm64")) + resolved.await() + onResolved() + signal.await() + return CliServer.State.Ready(mock.start(), mock.password) + } + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloBackendAppService.create(scope, server, log) + val job = scope.launch { svc.connect() } + + val downloading = withTimeout(5_000) { + svc.appState.first { it is KiloAppState.Downloading } + } + assertEquals(KiloAppState.Downloading(37, "1.2.3", "darwin-arm64"), downloading) + val dto = appStateDto(downloading) + assertEquals(37, dto.downloadPercent) + assertEquals("1.2.3", dto.downloadVersion) + assertEquals("darwin-arm64", dto.downloadPlatform) + + resolved.complete(Unit) + withTimeout(5_000) { + svc.appState.first { it == KiloAppState.Connecting } + } + + signal.complete(Unit) + ready(svc) + job.join() + } + @Test fun `shutdown for unload clears runtime and disposes server once`() = runBlocking { val server = FakeCliServer(mock) @@ -389,7 +433,7 @@ class KiloBackendAppServiceTest { val failing = object : ai.kilocode.backend.cli.CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error( + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = ai.kilocode.backend.cli.CliServer.State.Error( message = "CLI startup failed", details = "stderr: missing dependency", ) @@ -804,10 +848,36 @@ class KiloBackendAppServiceTest { assertEquals("alice@test.com", dto.profile?.email) assertEquals("Alice", dto.profile?.name) assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role) + // The pinned CLI does not expose hasPersonalAccount yet; the mapper defaults it to true. + assertTrue(dto.profile?.hasPersonalAccount ?: false) assertEquals(42.5, dto.profile?.balance?.balance) assertEquals("org_1", dto.profile?.currentOrgId) } + @Test + fun `ready dto hides unknown profile amounts`() = runBlocking { + mock.profile = """{ + "profile":{"email":"alice@test.com","name":"Alice"}, + "balance":{"balance":null}, + "kiloPass":{ + "currentPeriodBaseCreditsUsd":null, + "currentPeriodUsageUsd":null, + "currentPeriodBonusCreditsUsd":null, + "nextBillingAt":null + }, + "currentOrgId":null + }""".trimIndent() + val svc = create() + svc.connect() + + ready(svc) + + val dto = appStateDto(svc.appState.value) + assertEquals("alice@test.com", dto.profile?.email) + assertNull(dto.profile?.balance) + assertNull(dto.profile?.kiloPass) + } + @Test fun `refresh profile updates ready dto profile`() = runBlocking { mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 335b91e2e87..34cef9fecd2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -1,12 +1,14 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.CliDownload import ai.kilocode.backend.app.ConnectionState import ai.kilocode.backend.app.KiloConnectionService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.log.KiloLog +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -78,6 +80,45 @@ class KiloConnectionServiceTest { assertTrue(svc.api != null) } + @Test + fun `connect reports downloading then connecting before ready`() = runBlocking { + val resolved = CompletableDeferred() + val ready = CompletableDeferred() + val server = object : CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onProgress(CliDownload(42, "1.2.3", "darwin-arm64")) + resolved.await() + onResolved() + ready.await() + return CliServer.State.Ready(mock.start(), mock.password) + } + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloConnectionService(scope, server, {}, log) + val job = scope.launch { svc.connect() } + + val downloading = withTimeout(5_000) { + svc.state.first { it is ConnectionState.Downloading } + } + assertEquals(ConnectionState.Downloading(42, "1.2.3", "darwin-arm64"), downloading) + + resolved.complete(Unit) + withTimeout(5_000) { + svc.state.first { it == ConnectionState.Connecting } + } + + ready.complete(Unit) + mock.awaitSseConnection() + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + job.join() + } + @Test fun `SSE events are emitted`() = runBlocking { val svc = KiloConnectionService(scope, fake, {}, log) @@ -189,7 +230,7 @@ class KiloConnectionServiceTest { val failing = object : CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") override fun exited(proc: Process) {} override fun stop() {} override fun dispose() {} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt index 339dc9542d0..9024ce35614 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt @@ -79,6 +79,15 @@ class KiloBackendCliManagerEnvTest { assertEquals(cfg, env["KILO_CONFIG_CONTENT"]) } + @Test + fun `isolation disabled - base PATH is preserved`() { + val path = "/opt/homebrew/bin:/usr/bin" + + val env = manager.buildEnv("pwd123", mapOf("PATH" to path)) + + assertEquals(path, env["PATH"]) + } + @Test fun `isolation disabled - no XDG storage overrides are injected`() { val env = manager.buildEnv("pwd123", emptyMap()) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt new file mode 100644 index 00000000000..1c4d3131c0b --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -0,0 +1,318 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.File +import java.security.MessageDigest +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KiloCliDownloaderTest { + @TempDir + lateinit var dir: File + + @Test + fun `downloads extracts and caches pinned cli`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val seen = mutableListOf() + val log = TestLog() + val cli = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3", onProgress = { seen.add(it) }) + + assertTrue(cli.isFile) + assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath) + assertEquals("#!/bin/sh\n", cli.readText()) + assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile) + assertEquals("/api/v1.2.3", server.takeRequest().path) + assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) + assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first()) + assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() }) + assertTrue( + log.messages.any { + it.startsWith("INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ") && + it.contains("/.tmp/") + } + ) + + val cachedProgress = mutableListOf() + val cached = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3", onProgress = { cachedProgress.add(it) }) + assertEquals(cli.absolutePath, cached.absolutePath) + assertEquals(2, server.requestCount) + assertTrue(cachedProgress.isEmpty()) + assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") + + File(cli.parentFile.parentFile, ".complete").writeText("ok\n") + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val stale = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + assertEquals(cli.absolutePath, stale.absolutePath) + assertEquals(4, server.requestCount) + + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val forced = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3", force = true) + assertEquals(cli.absolutePath, forced.absolutePath) + assertEquals(6, server.requestCount) + } + } + + @Test + fun `prunes stale versions and removes the extracted archive`() = runBlocking { + MockWebServer().use { server -> + val stale = File(File(dir, "0.0.1"), KiloCliPlatform.current()) + assertTrue(stale.mkdirs()) + File(stale, "leftover").writeText("old") + + val bytes = archive() + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + + assertTrue(cli.isFile) + assertFalse(File(dir, "0.0.1").exists()) + val archived = File(cli.parentFile.parentFile, "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}") + assertFalse(archived.exists()) + } + } + + @Test + fun `forced resolve re-downloads and keeps only the active version`() = runBlocking { + MockWebServer().use { server -> + val first = archive("#!/bin/old\n") + val next = archive("#!/bin/new\n") + server.enqueue(metadata(first)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(first))) + server.enqueue(metadata(next)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(next))) + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ) + val old = cli.resolve("1.2.3") + assertEquals("#!/bin/old\n", old.readText()) + assertEquals(2, server.requestCount) + + val forced = cli.resolve("1.2.3", force = true) + assertTrue(forced.isFile) + assertEquals("#!/bin/new\n", forced.readText()) + assertEquals(4, server.requestCount) + assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.map { it.name }) + } + } + + @Test + fun `forced resolve keeps the existing cli when download fails`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive("#!/bin/old\n") + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable")) + + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + val ex = assertFailsWith { + KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3", force = true) + } + + assertContains(ex.message.orEmpty(), "Failed to download") + assertTrue(cli.isFile) + assertEquals("#!/bin/old\n", cli.readText()) + assertTrue(File(cli.parentFile.parentFile, ".complete").isFile) + } + } + + @Test + fun `rejects cli archive with mismatched digest`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(metadata("sha256:${sha256("different".toByteArray())}")) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + + val ex = assertFailsWith { + KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "digest mismatch") + assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists()) + } + } + + @Test + fun `fails clearly and logs when the release has no matching asset`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"other.zip","digest":"sha256:${"a".repeat(64)}"}]}""" + ) + ) + val log = TestLog() + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}" + assertContains(ex.message.orEmpty(), "has no asset named $name") + assertContains(ex.message.orEmpty(), "other.zip") + assertTrue(log.messages.any { it.contains("has no asset named $name") }) + } + } + + @Test + fun `fails clearly and logs when the asset has no digest`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"}]}""" + ) + ) + val log = TestLog() + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + assertContains(ex.message.orEmpty(), "has no digest yet") + assertTrue(log.messages.any { it.contains("has no digest yet") }) + } + } + + @Test + fun `reports github api rate limits while resolving metadata`() = runBlocking { + MockWebServer().use { server -> + val reset = 1_800_000_000L + server.enqueue( + MockResponse() + .setResponseCode(403) + .setHeader("X-RateLimit-Limit", "60") + .setHeader("X-RateLimit-Remaining", "0") + .setHeader("X-RateLimit-Used", "60") + .setHeader("X-RateLimit-Reset", reset.toString()) + .setBody("API rate limit exceeded") + ) + val log = TestLog() + + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "GitHub API rate limit exceeded") + assertContains(ex.message.orEmpty(), "remaining=0") + assertContains(ex.message.orEmpty(), "reset=2027-01-15T08:00:00Z") + assertTrue(log.messages.any { it.contains("GitHub API rate limit hit") && it.contains("remaining=0") }) + assertEquals("/api/v1.2.3", server.takeRequest().path) + assertEquals(1, server.requestCount) + } + } + + private fun archive(script: String = "#!/bin/sh\n"): ByteArray { + val files = mapOf( + "bin/${KiloCliPlatform.exe()}" to script.toByteArray(), + "bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(), + ) + if (KiloCliPlatform.archive() == "zip") return zip(files) + return tar(files) + } + + private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}") + + private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody( + """{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}""" + ) + + private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun zip(files: Map): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + files.forEach { entry -> + zip.putNextEntry(ZipEntry(entry.key)) + zip.write(entry.value) + zip.closeEntry() + } + } + return out.toByteArray() + } + + private fun tar(files: Map): ByteArray { + val out = ByteArrayOutputStream() + GzipCompressorOutputStream(out).use { gzip -> + TarArchiveOutputStream(gzip).use { tar -> + files.forEach { entry -> + val item = TarArchiveEntry(entry.key) + item.size = entry.value.size.toLong() + tar.putArchiveEntry(item) + tar.write(entry.value) + tar.closeArchiveEntry() + } + } + } + return out.toByteArray() + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt index aa29477ff96..e6eeca253d4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt @@ -1,6 +1,7 @@ package ai.kilocode.backend.testing import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.CliDownload /** * Fake [CliServer] that delegates to a [MockCliServer] instead of @@ -20,8 +21,10 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer { override fun process(): Process? = null - override suspend fun init(): CliServer.State = - CliServer.State.Ready(mock.start(), mock.password) + override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + onResolved() + return CliServer.State.Ready(mock.start(), mock.password) + } override fun exited(proc: Process) {} 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 8107264e804..817c8ca4af3 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 @@ -404,6 +404,20 @@ class KiloBackendWorkspaceTest { assertEquals("ses_1", result.sessions[0].id) } + @Test + fun `workspace maps missing session timestamps to zero`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}} + ]""" + val app = setup() + val ws = ready(app) + loaded(ws) + + val session = ws.sessions().sessions.single() + assertEquals(0.0, session.time.created) + assertEquals(0.0, session.time.updated) + } + @Test fun `workspace creates session in its directory`() = runBlocking { mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" diff --git a/packages/kilo-jetbrains/build-tasks/build.gradle.kts b/packages/kilo-jetbrains/build-tasks/build.gradle.kts index d2882538364..1fd055a01d3 100644 --- a/packages/kilo-jetbrains/build-tasks/build.gradle.kts +++ b/packages/kilo-jetbrains/build-tasks/build.gradle.kts @@ -8,6 +8,7 @@ repositories { dependencies { implementation(libs.kotlinx.serialization.json) + implementation(libs.commons.compress) testImplementation(kotlin("test")) } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt index ad42d56f559..1c25aaec60f 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt @@ -7,8 +7,8 @@ import org.gradle.api.Project * `id("build-tasks")` resolves in `backend/build.gradle.kts`. * * The real value lives in the custom task classes this composite build - * provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], - * [PrepareLocalCliTask], and [CheckCliTask]. + * provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and + * [GenerateOpenApiSpecTask]. */ class BuildTasksPlugin : Plugin { override fun apply(target: Project) {} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt deleted file mode 100644 index dffe15e5169..00000000000 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt +++ /dev/null @@ -1,59 +0,0 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import java.io.File - -/** - * Verify that CLI binaries exist before packaging the plugin. - * In production mode, all platform binaries must be present. - * In dev mode, only the current platform binary is required. - * - * CLI binaries must be prepared separately before packaging: - * Local: bun run build (from packages/kilo-jetbrains/) - * Production: bun run build:production - */ -abstract class CheckCliTask : DefaultTask() { - @get:InputDirectory - @get:Optional - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val dir: DirectoryProperty - - @get:Input - abstract val production: Property - - @get:Input - abstract val platforms: ListProperty - - @TaskAction - fun run() { - val resolved = dir.get().asFile - if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) { - throw GradleException( - "CLI binaries not found at ${resolved.absolutePath}.\n" + - "Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together." - ) - } - if (production.get()) { - val missing = platforms.get().filter { platform -> - val d = File(resolved, platform) - val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo" - !File(d, exe).exists() - } - if (missing.isNotEmpty()) { - throw GradleException( - "Production build requires all platform CLI binaries.\n" + - "Missing: ${missing.joinToString(", ")}\n" + - "Run 'bun run build:production' to build all platforms." - ) - } - } - } -} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index f594d0f06ba..de463945f87 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -1,50 +1,48 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputDirectory +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction -import javax.inject.Inject import org.gradle.process.ExecOperations import java.io.ByteArrayOutputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest +import java.time.Instant +import java.util.zip.ZipInputStream +import javax.inject.Inject /** - * Generates the CLI OpenAPI spec into the build directory so the JetBrains - * Gradle build is self-contained and does not mutate the tracked - * packages/sdk/openapi.json. - * - * Runs `bun dev generate` from the opencode package directory and captures - * stdout to [spec]. stderr is captured separately and included in the error - * message on failure. - * - * Gradle up-to-date tracking is scoped to [serverSrcDir] (the opencode server - * source) to avoid busting the cache on unrelated changes to dist/, node_modules/, - * etc. + * Generates the CLI OpenAPI spec from the pinned release binary. */ abstract class GenerateOpenApiSpecTask : DefaultTask() { + companion object { + private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") + private val JSON = Json { ignoreUnknownKeys = true } + private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags" + } - /** - * The server source directory inside the opencode package — the only files - * that affect the OpenAPI output. Scoped to `src/server/` to avoid busting - * the Gradle up-to-date check on unrelated file changes (dist/, node_modules/). - */ - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val serverSrcDir: DirectoryProperty + @get:Input + abstract val cliVersion: Property - /** - * Root of the `packages/opencode/` package — the working directory for bun. - * Marked @Internal because it is not itself a Gradle input; only [serverSrcDir] - * (a subdirectory) participates in up-to-date checking. - */ @get:Internal - abstract val opencodeDir: DirectoryProperty + abstract val token: Property + + @get:Internal + abstract val cacheDir: DirectoryProperty - /** Destination file for the generated openapi.json. */ @get:OutputFile abstract val spec: RegularFileProperty @@ -53,52 +51,206 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @TaskAction fun run() { + val kilo = resolve() val out = ByteArrayOutputStream() val err = ByteArrayOutputStream() val result = exec.exec { - workingDir = opencodeDir.get().asFile - commandLine(findBun(), "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(kilo.absolutePath, "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true } if (result.exitValue != 0) { throw GradleException( - "bun dev generate failed with exit code ${result.exitValue}.\n" + - err.toString(Charsets.UTF_8).take(2000) + "kilo generate failed with exit code ${result.exitValue}.\n" + + err.toString(Charsets.UTF_8).take(2000) ) } val json = out.toString(Charsets.UTF_8) if (!json.trimStart().startsWith("{")) { throw GradleException( - "bun dev generate did not produce JSON.\n" + - "stdout: ${json.take(200)}\n" + - "stderr: ${err.toString(Charsets.UTF_8).take(500)}" + "kilo generate did not produce JSON.\n" + + "stdout: ${json.take(200)}\n" + + "stderr: ${err.toString(Charsets.UTF_8).take(500)}" ) } spec.get().asFile.also { it.parentFile.mkdirs() }.writeText(json) } - private fun findBun(): String { - val which = runCatching { - ProcessBuilder("which", "bun") - .redirectErrorStream(true) - .start() - .inputStream.bufferedReader().readLine()?.trim() - }.getOrNull() - if (which != null && java.io.File(which).isFile) return which - - val home = System.getProperty("user.home") - val candidates = listOf( - "$home/.bun/bin/bun", - "/opt/homebrew/bin/bun", - "/usr/local/bin/bun", - "$home/.nvm/current/bin/bun", - ) - for (path in candidates) { - val f = java.io.File(path) - if (f.isFile && f.canExecute()) return f.absolutePath + private fun resolve(): File { + val version = cliVersion.get() + val platform = platform() + val ext = if (platform.startsWith("linux-")) "tar.gz" else "zip" + val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile + val exe = File(dir, "bin/${exe()}") + val done = File(dir, ".complete") + val cached = done.takeIf { it.isFile }?.readText()?.trim() + val archive = File(dir, "kilo-$platform.$ext") + if (exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) { + if (!windows()) exe.setExecutable(true) + return exe } - return "bun" + + val name = "kilo-$platform.$ext" + val digest = asset(version, name) + if (dir.exists() && !dir.deleteRecursively()) { + throw GradleException("Failed to delete cached pinned Kilo CLI under ${dir.absolutePath}") + } + if (!dir.isDirectory && !dir.mkdirs()) { + throw GradleException("Failed to create pinned Kilo CLI cache directory ${dir.absolutePath}") + } + download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive) + verify(archive, digest) + extract(archive, dir) + if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}") + if (!windows()) exe.setExecutable(true) + done.writeText("$digest\n") + return exe } + + private fun asset(version: String, name: String): String { + val url = "$API/v$version" + logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url") + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.instanceFollowRedirects = true + conn.setRequestProperty("Accept", "application/vnd.github+json") + token.getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + try { + val code = conn.responseCode + if (code !in 200..299) { + val info = rate(conn) + val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } } + .getOrNull() + ?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(conn, code)) { + throw GradleException( + "GitHub API rate limit exceeded while fetching pinned Kilo CLI release metadata ($info)$detail" + ) + } + throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url ($info)$detail") + } + val body = conn.inputStream.bufferedReader().use { it.readText() } + val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } + ?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull + ?: throw GradleException("Pinned Kilo CLI release $version did not include $name") + if (!digest.matches(DIGEST)) throw GradleException("Pinned Kilo CLI release $version asset $name has invalid digest") + return digest + } finally { + conn.disconnect() + } + } + + private fun rate(conn: HttpURLConnection): String { + val reset = conn.getHeaderField("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " + + "used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}" + } + + private fun limited(conn: HttpURLConnection, code: Int) = + code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0") + + private fun download(url: String, file: File) { + logger.lifecycle("Downloading pinned Kilo CLI from $url") + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.instanceFollowRedirects = true + try { + val code = conn.responseCode + if (code !in 200..299) throw GradleException("Failed to download pinned Kilo CLI: HTTP $code from $url") + conn.inputStream.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + } finally { + conn.disconnect() + } + } + + private fun verify(file: File, digest: String) { + val actual = sum(file) + if (actual == digest) return + if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}") + throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual") + } + + private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest + + private fun sum(file: File) = "sha256:${sha256(file)}" + + private fun sha256(file: File): String { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val n = input.read(buffer) + if (n < 0) break + md.update(buffer, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + + private fun extract(file: File, dir: File) { + if (file.name.endsWith(".zip")) { + ZipInputStream(file.inputStream().buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) } + zip.closeEntry() + } + } + return + } + TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar -> + while (true) { + val entry = tar.nextEntry ?: break + write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) } + } + } + } + + private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) { + val path = if (name.startsWith("bin/")) name else "bin/$name" + val target = File(dir, path).canonicalFile + val base = dir.canonicalFile + if (target != base && !target.path.startsWith(base.path + File.separator)) { + throw GradleException("Archive entry escapes target directory: $name") + } + if (directory) { + target.mkdirs() + return + } + target.parentFile.mkdirs() + target.outputStream().use(copy) + if (!windows() && (target.name == "kilo" || target.name == "bwrap")) target.setExecutable(true) + } + + private fun platform(): String { + val os = System.getProperty("os.name").lowercase() + val name = when { + os.contains("mac") || os.contains("darwin") -> "darwin" + os.contains("linux") -> "linux" + os.contains("windows") -> "windows" + else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}") + } + val arch = when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> "arm64" + "x86_64", "amd64" -> "x64" + else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}") + } + return "$name-$arch" + } + + private fun exe() = if (windows()) "kilo.exe" else "kilo" + + private fun windows() = System.getProperty("os.name").lowercase().contains("windows") } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt deleted file mode 100644 index dc1501154f7..00000000000 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt +++ /dev/null @@ -1,139 +0,0 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations -import org.gradle.work.DisableCachingByDefault -import java.io.File -import javax.inject.Inject - -/** - * Prepare the local-platform CLI binary for JetBrains backend runs. - * - * Runs `bun run build --prepare-cli` from the [root] project directory when - * the expected current-platform binary is absent from [dir]. Skips - * immediately when the binary already exists. - * - * Bun is found in the following priority order: - * 1. Explicit `-Pkilo.bun.path=...` Gradle property (forwarded via [bunPath]) - * 2. `BUN_EXE` environment variable (absolute path to the bun executable) - * 3. `BUN_INSTALL/bin/bun` (from `BUN_INSTALL` environment variable) - * 4. Each entry on `PATH` - * 5. Common install locations (`~/.bun/bin`, `/opt/homebrew/bin`, `/usr/local/bin`) - * - * After Bun finishes, the task verifies the expected binary exists so that Rosetta / architecture - * mismatches surface with a clear error message. - */ -@DisableCachingByDefault(because = "Local developer bootstrap task that shells out to Bun") -abstract class PrepareLocalCliTask : DefaultTask() { - @get:Internal - abstract val root: DirectoryProperty - - @get:OutputDirectory - abstract val dir: DirectoryProperty - - @get:Input - @get:Optional - abstract val bunPath: Property - - @get:Inject - abstract val exec: ExecOperations - - @TaskAction - fun run() { - val expected = binary() - if (expected.exists()) { - logger.lifecycle("CLI binary already exists at ${expected.absolutePath}") - return - } - - val bun = findBun() - logger.lifecycle("Preparing local CLI binary with ${bun.absolutePath}") - val result = exec.exec { - workingDir = root.get().asFile - executable = bun.absolutePath - args("run", "build", "--prepare-cli") - isIgnoreExitValue = true - } - - if (result.exitValue != 0) { - throw GradleException("bun run build --prepare-cli failed with exit code ${result.exitValue}") - } - - if (!expected.exists()) { - throw GradleException( - "Expected CLI binary was not created at ${expected.absolutePath}. " + - "This can happen if Bun and Gradle are running under different architectures." - ) - } - } - - private fun binary(): File { - return File(File(dir.get().asFile, platform()), exe()) - } - - private fun platform(): String { - return "${os()}-${arch()}" - } - - private fun os(): String { - val name = System.getProperty("os.name").lowercase() - if ("mac" in name || "darwin" in name) return "darwin" - if ("linux" in name) return "linux" - if ("windows" in name) return "windows" - throw GradleException("Unsupported OS for JetBrains CLI binary: $name") - } - - private fun arch(): String { - return when (val value = System.getProperty("os.arch").lowercase()) { - "aarch64", "arm64" -> "arm64" - "x86_64", "amd64" -> "x64" - else -> throw GradleException("Unsupported architecture for JetBrains CLI binary: $value") - } - } - - private fun exe(): String { - if (os() == "windows") return "kilo.exe" - return "kilo" - } - - private fun findBun(): File { - val configured = bunPath.orNull?.trim()?.takeIf { it.isNotEmpty() } - if (configured != null) return requireBun(File(configured)) - - val names = if (os() == "windows") listOf("bun.exe", "bun.cmd", "bun.bat") else listOf("bun") - val candidates = mutableListOf() - - System.getenv("BUN_EXE")?.trim()?.takeIf { it.isNotEmpty() }?.let { candidates.add(File(it)) } - System.getenv("BUN_INSTALL")?.trim()?.takeIf { it.isNotEmpty() }?.let { install -> - candidates.addAll(names.map { File(File(install, "bin"), it) }) - } - System.getenv("PATH")?.split(File.pathSeparator)?.forEach { entry -> - candidates.addAll(names.map { File(entry, it) }) - } - - val home = System.getProperty("user.home") - candidates.addAll(names.map { File(File(home, ".bun/bin"), it) }) - candidates.addAll(names.map { File("/opt/homebrew/bin", it) }) - candidates.addAll(names.map { File("/usr/local/bin", it) }) - - return candidates.firstOrNull(::usable) - ?: throw GradleException("Could not find Bun. Install Bun or pass -Pkilo.bun.path=/absolute/path/to/bun.") - } - - private fun requireBun(file: File): File { - if (usable(file)) return file - throw GradleException("Configured Bun executable is not usable: ${file.absolutePath}") - } - - private fun usable(file: File): Boolean { - if (!file.isFile) return false - if (os() == "windows") return true - return file.canExecute() - } -} diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 50c07ca0044..0b0a136e627 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -221,7 +221,6 @@ tasks { runIdeBackend { splitModeServerPort.set(splitPort) - dependsOn(":backend:prepareLocalCli") dependsOn(":backend:processResources") } @@ -231,17 +230,12 @@ tasks { runIdeSplitMode { splitModeServerPort.set(splitPort) - dependsOn(":backend:prepareLocalCli") dependsOn(":backend:processResources") } } -project(":backend").tasks.named("processResources") { - mustRunAfter(":backend:prepareLocalCli") -} - // Compile-only typecheck: verifies Kotlin compiles (including generated API client) -// without running processResources, CLI binary prep, or buildPlugin. +// without running buildPlugin. tasks.register("typecheck") { dependsOn( ":shared:compileKotlin", @@ -252,12 +246,6 @@ tasks.register("typecheck") { ) } -// CLI binaries must be present before packaging. Wire the check here (not in -// :backend:processResources) so compile/test tasks work without CLI binaries. -tasks.named("buildPlugin") { - dependsOn(":backend:checkCli") -} - tasks.named("runIde") { dependsOn(":backend:processResources") jvmArgumentProviders += CommandLineArgumentProvider { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt new file mode 100644 index 00000000000..1bad925ec0c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware + +class CoreInfoAction : AnAction(), DumbAware { + override fun actionPerformed(e: AnActionEvent) = Unit + + override fun update(e: AnActionEvent) { + val app = service() + val info = app.core + if (info == null) app.fetchCoreInfoAsync() + e.presentation.text = info?.let { + KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform) + } ?: KiloBundle.message("action.Kilo.CoreInfo.loading") + e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description") + e.presentation.isEnabled = false + e.presentation.isVisible = true + } + + 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 d18532b7394..b24e42f992f 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 @@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** - * App-level frontend service for Kilo CLI interaction. + * App-level frontend service for Kilo Core interaction. * * Communicates with the backend via [KiloAppRpcApi]. All operations * are app-scoped — no project context is needed. @@ -47,10 +47,16 @@ class KiloAppService internal constructor( private val started = AtomicBoolean(false) - /** CLI version string from the last successful health check, or null if unknown. */ + /** Core version and platform from the backend, or null if unknown. */ @Volatile - var version: String? = null - private set + private var info: CoreInfo? = null + private val coreLock = Any() + private val coreDone = mutableListOf<(CoreInfo?) -> Unit>() + private var coreJob: Job? = null + + val core: CoreInfo? get() = info + + val version: String? get() = info?.version internal val _state = MutableStateFlow(init) val state: StateFlow = _state.asStateFlow() @@ -79,6 +85,9 @@ class KiloAppService internal constructor( } private fun onState(state: KiloAppStateDto) { + val version = state.downloadVersion + val platform = state.downloadPlatform + if (version != null && platform != null) info = CoreInfo(version, platform) _state.value = state if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync() } @@ -96,20 +105,20 @@ class KiloAppService internal constructor( call { retry() } } - /** Kill the CLI process and restart it. */ + /** Kill the Core process and restart it. */ suspend fun restart() { LOG.info("restart: resetting state and sending RPC") started.set(false) - version = null + info = null call { restart() } LOG.info("restart: RPC returned — backend restart complete") } - /** Kill the CLI process, re-extract the binary, and restart. */ + /** Kill the Core process, re-download the binary, and restart. */ suspend fun reinstall() { LOG.info("reinstall: resetting state and sending RPC") started.set(false) - version = null + info = null call { reinstall() } LOG.info("reinstall: RPC returned — backend reinstall complete") } @@ -131,18 +140,58 @@ class KiloAppService internal constructor( cs.launch { reinstall() } } - /** Fetch the CLI version and cache it. Call once after connection is established. */ - fun fetchVersionAsync() { - cs.launch { - LOG.info("fetchVersion: requesting health check") - val dto = health() - if (dto == null) { - LOG.warn("fetchVersion: health check returned null — version not available") - return@launch - } - version = dto.version - LOG.info("fetchVersion: CLI version is ${dto.version}") + suspend fun coreInfo(): CoreInfo? = try { + val next = CoreInfo( + version = call { cliVersion() }, + platform = call { cliPlatform() }, + ) + info = next + next + } catch (e: Exception) { + LOG.warn("core info failed", e) + null + } + + /** Fetch the pinned Core version and platform and cache it. */ + fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) { + val cached = info + if (cached != null) { + done(cached) + return } + + synchronized(coreLock) { + val current = info + if (current != null) { + done(current) + return + } + coreDone.add(done) + if (coreJob != null) return + coreJob = cs.launch { + fetchCoreInfo() + } + } + } + + private suspend fun fetchCoreInfo() { + LOG.info("fetchCoreInfo: requesting Core version and platform") + val next = coreInfo() + val list = synchronized(coreLock) { + val items = coreDone.toList() + coreDone.clear() + coreJob = null + items + } + list.forEach { it(next) } + if (next != null) { + LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})") + } + } + + /** Fetch the pinned Core version and cache it. */ + fun fetchVersionAsync(done: (String?) -> Unit = {}) { + fetchCoreInfoAsync { done(it?.version) } } fun refreshModelFavoritesAsync() { @@ -298,7 +347,7 @@ class KiloAppService internal constructor( fun watch(fn: (KiloAppStateDto) -> Unit): Job { return cs.launch { state.collect { next -> - if (next.status == KiloAppStatusDto.READY) fetchVersionAsync() + if (next.status == KiloAppStatusDto.READY) fetchCoreInfoAsync() fn(next) } } @@ -313,6 +362,8 @@ class KiloAppService internal constructor( } } +data class CoreInfo(val version: String, val platform: String) + private fun summary(patch: ConfigPatchDto): String { val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } return "values=$values agents=${patch.agents.size}" 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 2e7945cd4ef..8777b78cee2 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 @@ -201,11 +201,11 @@ class SessionUi( Disposer.register(this, popup) buildUi() Disposer.register(this, selection) + applyStyle(style) scroll.show(body(controller.model.state)) bindUi() bindStyle() bindMigration() - applyStyle(style) onStateChanged(controller.model.state) loaded?.let(::finishOpen) } @@ -307,9 +307,7 @@ class SessionUi( sessionContent = JPanel(BorderLayout()) - blankBody = JPanel(BorderLayout()).apply { - isOpaque = false - } + blankBody = JPanel(BorderLayout()) load = LoadingPanel() progressBody = load @@ -605,8 +603,8 @@ class SessionUi( } private fun body(state: SessionState): JPanel { - if (state is SessionState.Retry || state is SessionState.Offline) return progressBody if (controller.model.showSession) return messageBody + if (state is SessionState.Retry || state is SessionState.Offline) return progressBody if (state is SessionState.Loading) return progressBody return blankBody } @@ -786,6 +784,7 @@ class SessionUi( editorTheme = style.editorScheme colorTheme = UIManager.getLookAndFeel() background = style.editorBackground + root.background = style.editorBackground root.content.background = style.editorBackground sessionContent.background = style.editorBackground blankBody.background = style.editorBackground 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 f7b3e9a5400..468c4b8bf05 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 @@ -1094,7 +1094,8 @@ class SessionController( } else { snapshots.remove(key) } - if (model.state is SessionState.Busy) { + val s = model.state + if (s is SessionState.Busy || s is SessionState.Retry || s is SessionState.Offline) { model.setState(SessionState.Busy(status())) } if (child != null) trackChild(key, child) @@ -1833,6 +1834,14 @@ class SessionController( ) } + if (app.status == KiloAppStatusDto.DOWNLOADING) { + return SessionControllerEvent.ConnectionChanged.ShowDownloading( + app.downloadPercent ?: 0, + app.downloadVersion, + app.downloadPlatform, + ) + } + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { return SessionControllerEvent.ConnectionChanged.ShowError( KiloBundle.message("session.connection.error.workspace"), 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 50f644fe327..c6a4985b28c 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 @@ -63,6 +63,10 @@ sealed class SessionControllerEvent { override fun toString() = "ConnectionChanged connecting" } + data class ShowDownloading(val percent: Int, val version: String? = null, val platform: String? = null) : ConnectionChanged() { + override fun toString() = "ConnectionChanged downloading $percent" + } + data class ShowError(val summary: String, val detail: String?, val source: String = "app") : ConnectionChanged() { override fun toString() = "ConnectionChanged error $source" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt index a3265f2c1c7..12971f4e695 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt @@ -13,7 +13,6 @@ 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.FlowLayout import java.awt.Point import java.awt.Rectangle import java.awt.Component @@ -68,11 +67,11 @@ internal open class HistoryRenderer( verticalAlignment = SwingConstants.CENTER border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) } - private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { - add(title) - add(badge) + private val head = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)).apply { + add(title, BorderLayout.CENTER) + add(badge, BorderLayout.EAST) } - private val main = JPanel(BorderLayout()).apply { + private val main = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { add(head, BorderLayout.CENTER) add(time, BorderLayout.EAST) } @@ -86,7 +85,12 @@ internal open class HistoryRenderer( init { isOpaque = true top.isOpaque = true - row.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) + row.border = JBUI.Borders.empty( + UiStyle.Gap.lg(), + UiStyle.Gap.lg(), + UiStyle.Gap.lg(), + UiStyle.Gap.lg(), + ) UiStyle.Components.transparent(row, main, head, title, badge, time, del) wrap.setContent(row) add(top, BorderLayout.NORTH) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt index 3d969a291e7..f521687e4d0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -6,7 +6,9 @@ import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.HoverIcon -import ai.kilocode.client.ui.iconButton +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import com.intellij.icons.AllIcons @@ -50,6 +52,7 @@ import javax.swing.JComponent import javax.swing.JList import javax.swing.KeyStroke import javax.swing.ListSelectionModel +import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities import javax.swing.event.DocumentEvent import javax.swing.event.ListDataEvent @@ -101,7 +104,7 @@ class HistoryPanel( init { Disposer.register(parent, this) - border = JBUI.Borders.empty(UiStyle.Gap.lg()) + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), 0) more.addActionListener { controller.loadMoreCloud() } bind(localList, controller.local) bind(cloudList, controller.cloud) @@ -185,7 +188,7 @@ class HistoryPanel( ) } - private fun back(): BorderLayoutPanel { + private fun back(): JComponent { val label = KiloBundle.message("history.back") val btn = HoverIcon().apply { icon = AllIcons.Actions.Back @@ -194,8 +197,7 @@ class HistoryPanel( cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) addActionListener { nav() } } - return BorderLayoutPanel().apply { - add(btn, BorderLayout.WEST) + return btn.align(HAlign.LEFT, VAlign.CENTER).apply { border = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) } } @@ -207,11 +209,13 @@ class HistoryPanel( if (list === cloudList) { add(repoOnly, BorderLayout.SOUTH) } + border = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) } add(north, BorderLayout.NORTH) add(JBScrollPane(list).apply { border = JBUI.Borders.empty() - viewportBorder = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER }, BorderLayout.CENTER) footer?.let { add(Centerizer(it, Centerizer.TYPE.HORIZONTAL).apply { @@ -221,7 +225,7 @@ class HistoryPanel( } } - private fun localList() = JBList(controller.local).apply { + private fun localList() = HistoryList(controller.local).apply { selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION isFocusable = true cellRenderer = LocalHistoryRenderer(controller.local, { snapshot.activity }, { snapshot.titles }) @@ -249,7 +253,7 @@ class HistoryPanel( ScrollingUtil.installActions(this) } - private fun cloudList() = JBList(controller.cloud).apply { + private fun cloudList() = HistoryList(controller.cloud).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION isFocusable = true cellRenderer = CloudHistoryRenderer(controller.cloud) { snapshot.activity } @@ -548,6 +552,10 @@ class HistoryPanel( } } + private class HistoryList(model: HistoryModel) : JBList(model) { + override fun getScrollableTracksViewportWidth() = true + } + internal fun showingLoading() = !controller.local.loaded && !controller.cloud.loaded && (controller.local.loading || controller.cloud.loading) private companion object { 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 424cfeb62a0..dd891fa1ccc 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 @@ -5,7 +5,6 @@ import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel 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.UiStyle import com.intellij.openapi.application.ApplicationManager import com.intellij.ui.components.JBLabel @@ -35,6 +34,8 @@ internal class SessionScroll( private const val FOLLOW_PASSES = 6 } + private var style = SessionEditorStyle.current() + val component = JBScrollPane(body).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED @@ -46,7 +47,6 @@ internal class SessionScroll( val view: JComponent? get() = component.viewport.view as? JComponent var onScroll: (() -> Unit)? = null - private var style = SessionEditorStyle.current() private var tail = true private var auto = false private var opening = false @@ -202,8 +202,8 @@ internal class SessionScroll( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { this.style = style - component.background = SessionUiStyle.Transcript.bgColor() - component.viewport.background = SessionUiStyle.Transcript.bgColor() + component.background = style.editorBackground + component.viewport.background = style.editorBackground syncIcon() messages.applyStyle(style) val view = component.viewport.view diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index ed563da8844..2aaf2acf711 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -120,6 +120,8 @@ class ConnectionPanel( is SessionControllerEvent.ConnectionChanged.ShowConnecting -> showConnecting() + is SessionControllerEvent.ConnectionChanged.ShowDownloading -> showDownloading(event.percent, event.version, event.platform) + is SessionControllerEvent.ConnectionChanged.ShowError -> { showError(event.summary, event.detail) showPanel() @@ -145,6 +147,22 @@ class ConnectionPanel( showPanel() } + private fun showDownloading(percent: Int, version: String?, platform: String?) { + label.foreground = UiStyle.Colors.weak() + val pct = percent.coerceIn(0, 100) + label.text = if (version != null && platform != null) { + KiloBundle.message("session.connection.downloading.version", version, platform, pct) + } else { + KiloBundle.message("session.connection.downloading", pct) + } + detail = null + expanded = false + toggle.isVisible = false + retry.isVisible = false + renderDetails() + showPanel() + } + private fun showError(text: String, detail: String?) { label.foreground = UiStyle.Colors.errorLabelForeground() label.text = text 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 5a9d6d757cb..bbc44bd12ca 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 @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -20,6 +21,8 @@ import com.intellij.util.ui.JBUI * * Reacts to [SessionModelEvent.StateChanged]: * - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text] + * - [SessionState.Retry] → shows an animated spinner and retry detail + * - [SessionState.Offline] → shows offline detail without a spinner * - Any other state -> hidden * * Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it @@ -30,9 +33,12 @@ class ProgressPanel( parent: Disposable, ) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget { + private var style = SessionEditorStyle.current() + private var state: SessionState = SessionState.Idle private val label = JBLabel().apply { - foreground = UiStyle.Colors.weak() + foreground = style.editorForeground } + private val spinner = JBLabel(AnimatedIcon.Default()) init { isOpaque = false @@ -45,7 +51,7 @@ class ProgressPanel( ) applyStyle(SessionEditorStyle.current()) - next(JBLabel(AnimatedIcon.Default())) + next(spinner) next(label) model.addListener(parent) { event -> @@ -56,22 +62,47 @@ class ProgressPanel( /** Exposed for test assertions. */ fun labelText(): String = label.text + /** Exposed for test assertions. */ + fun labelForeground() = label.foreground + private fun onState(state: SessionState) { + this.state = state when (state) { is SessionState.Busy -> { + spinner.isVisible = true label.text = state.text - label.foreground = UiStyle.Colors.weak() + label.foreground = style.editorForeground + isVisible = true + } + is SessionState.Retry -> { + spinner.isVisible = true + label.text = retryText(state) + label.foreground = UiStyle.Colors.warningLabelForeground() + isVisible = true + } + is SessionState.Offline -> { + spinner.isVisible = false + label.text = state.message.ifBlank { KiloBundle.message("session.status.offline") } + label.foreground = UiStyle.Colors.errorLabelForeground() isVisible = true } - is SessionState.Loading -> isVisible = false else -> isVisible = false } revalidate() repaint() } + private fun retryText(state: SessionState.Retry): String { + val base = state.message.ifBlank { KiloBundle.message("session.status.retry") } + return if (state.attempt > 0) { + KiloBundle.message("session.status.retry.attempt", base, state.attempt) + } else base + } + override fun applyStyle(style: SessionEditorStyle) { + this.style = style label.font = style.regularFont + if (state is SessionState.Busy) label.foreground = style.editorForeground 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 9d0c9186e5d..7bed451f959 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 @@ -82,8 +82,8 @@ class SessionMessageListPanel( init { isOpaque = true - background = SessionUiStyle.Transcript.bgColor() Disposer.register(parent, this) + applyStyle(style) model.addListener(parent) { event -> when (event) { @@ -385,7 +385,7 @@ class SessionMessageListPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = SessionUiStyle.Transcript.bgColor() + background = style.editorBackground for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) 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 index 82a04299116..7a05e01202a 100644 --- 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 @@ -124,7 +124,8 @@ internal class SessionAccountOverlay( var layout = false val orgs = prof.organizations - val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) + + val personal = prof.hasPersonalAccount + val next = (if (personal) listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) else emptyList()) + orgs.map { org -> AccountChoice(org.id, org.name) } if (next != choices) { choices = next @@ -133,7 +134,7 @@ internal class SessionAccountOverlay( if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId - val activeId = if (switching) target else prof.currentOrgId + val activeId = if (switching) target else prof.currentOrgId ?: if (personal) null else orgs.firstOrNull()?.id val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull() val title = "${active?.title ?: " "} ▾" if (picker.text != title) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt index e4dc8665544..a0e0daf0a55 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt @@ -1,8 +1,14 @@ package ai.kilocode.client.session.ui.popup import com.intellij.openapi.Disposable +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout import java.awt.Color +import java.awt.Container +import java.awt.Dimension import javax.swing.JComponent +import javax.swing.JEditorPane +import javax.swing.JPanel class HeaderPopupRequest( val anchor: JComponent, @@ -11,7 +17,60 @@ class HeaderPopupRequest( ) class HeaderPopupBody( - val component: JComponent, + component: JComponent, val disposable: Disposable, val background: Color, -) +) { + val component: JComponent = HeaderPopupPanel(component) +} + +private class HeaderPopupPanel(private val child: JComponent) : JPanel(BorderLayout()) { + init { + // Transparent so the balloon fill shows uniformly behind nested popup content. + isOpaque = false + add(child, BorderLayout.CENTER) + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + val cap = JBUI.scale(350) + val width = size.width.takeIf { it > 0 }?.coerceAtMost(cap) ?: cap + fit(child, width) + val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(450)) + return Dimension(width, height) + } + + private fun fit(item: JComponent, width: Int) { + if (width <= 0) return + // JBHtmlPane derives wrapped preferred height from the current width, not just HTML content. + item.setSize(width, Short.MAX_VALUE.toInt()) + layout(item, width) + reset(item) + } + + private fun layout(item: Container, width: Int) { + if (item is JEditorPane) { + item.preferredSize = null + item.setSize(width, Short.MAX_VALUE.toInt()) + item.preferredSize = Dimension(width, item.preferredSize.height) + item.size = item.preferredSize + return + } + item.doLayout() + val insets = item.insets + val inner = (width - insets.left - insets.right).coerceAtLeast(0) + for (child in item.components) { + val nested = child as? Container ?: continue + val next = child.width.takeIf { it > 0 }?.coerceAtMost(inner) ?: inner + layout(nested, next) + } + } + + private fun reset(item: Container) { + item.invalidate() + for (child in item.components) { + val nested = child as? Container ?: continue + reset(nested) + } + } +} 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 19db08ab224..ff04e16a265 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 @@ -42,6 +42,7 @@ import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider import com.intellij.openapi.editor.colors.CodeInsightColors import com.intellij.openapi.editor.colors.TextAttributesKey import com.intellij.openapi.editor.event.CaretEvent @@ -133,6 +134,7 @@ class PromptPanel( var onAutoApproveToggle: (Boolean) -> Unit = {} var onFileDrag: (Boolean) -> Unit = {} private var style = SessionEditorStyle.current() + private var focused = false private val shell = BorderLayoutPanel().apply { isOpaque = true border = JBUI.Borders.empty( @@ -170,13 +172,19 @@ class PromptPanel( style.applyTranscriptToEditor(ed) ed.setBorder(JBUI.Borders.empty()) ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty( + 0, + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), + 0, + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), + ) ed.backgroundColor = style.editorScheme.defaultBackground ed.scrollPane.background = style.editorScheme.defaultBackground ed.scrollPane.viewport.background = style.editorScheme.defaultBackground ed.settings.isUseSoftWraps = true ed.settings.isPaintSoftWraps = false ed.settings.isAdditionalPageAtBottom = false + SpellCheckingEditorCustomizationProvider.getInstance().getDisabledCustomization()?.customize(ed) ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) }) ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED @@ -198,11 +206,11 @@ class PromptPanel( }) ed.contentComponent.addFocusListener(object : FocusAdapter() { override fun focusGained(e: FocusEvent) { - repaint() + syncFocus(true) } override fun focusLost(e: FocusEvent) { - repaint() + syncFocus(false) } }) } @@ -301,8 +309,27 @@ class PromptPanel( override fun updateUI() { super.updateUI() + syncBorder() + } + + private fun syncFocus(value: Boolean) { + if (focused == value) { + repaint() + return + } + focused = value + syncBorder() + revalidate() + repaint() + } + + private fun syncBorder() { border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(SessionUiStyle.View.Prompt.separator()), + if (focused) { + JBUI.Borders.emptyTop(JBUI.scale(1)) + } else { + JBUI.Borders.customLineTop(SessionUiStyle.View.Prompt.separator()) + }, JBUI.Borders.empty(), ) } 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 5071f431b6d..95e3b494af3 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 @@ -16,8 +16,13 @@ object SessionUiStyle { /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { const val GAP = 3 - val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm()) const val TRANSCRIPT_SCROLLBAR_PADDING = 10 + val InnerInsets = Insets( + UiStyle.Gap.md(), + UiStyle.Gap.sm() + TRANSCRIPT_SCROLLBAR_PADDING, + UiStyle.Gap.sm(), + UiStyle.Gap.sm(), + ) const val USER_PROMPT_INDENT = 100 const val SCROLL_INCREMENT = 48 } @@ -73,6 +78,8 @@ object SessionUiStyle { const val CONTROL_GAP = 4 const val SHELL_VERTICAL_PADDING = 6 const val SHELL_HORIZONTAL_PADDING = 8 + // Horizontal editor inset intentionally matches vertical shell padding to balance text and chrome. + const val EDITOR_HORIZONTAL_INSET = SHELL_VERTICAL_PADDING fun separator(): Color = JBColor.namedColor( "EditorTabs.underTabsBorderColor", @@ -144,17 +151,12 @@ object SessionUiStyle { const val MIN_ROWS = 1 const val BORDER_WIDTH = 1 const val VIEWPORT_TOP_PADDING = 6 - const val VIEWPORT_HORIZONTAL_PADDING = 8 + const val VIEWPORT_HORIZONTAL_PADDING = Layout.HORIZONTAL_PADDING const val VIEWPORT_BOTTOM_PADDING = 6 const val SCROLLBAR_HEIGHT = 12 const val WIDTH_PADDING = 16 - fun topPadding(): Int = VIEWPORT_TOP_PADDING - } - - object Popup { - const val MAX_LINES = 15 - const val MAX_WIDTH = 520 + fun topPadding(): Int = VIEWPORT_TOP_PADDING + UiStyle.Gap.lg() } /** Permission session-view command preview limits. */ 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 1b01027e57c..01c2e9fa9c8 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 @@ -7,10 +7,13 @@ import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.openSessionLink import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory @@ -63,6 +66,7 @@ class ReasoningView( private var done = reasoning.done private var registered = false private var following = false + private var pinned = false init { row.border = JBUI.Borders.empty( @@ -100,6 +104,7 @@ class ReasoningView( var changed = false val next = content.content.toString() val follow = tailVisible() + val finishing = !done && content.done if (done != content.done) { done = content.done changed = true @@ -112,10 +117,30 @@ class ReasoningView( } changed = true } + if (finishing && !pinned) { + changed = collapse() || changed + changed = releaseBody() || changed + } changed = sync() || changed if (changed) refresh() } + /** Detaches and disposes the markdown body so its editors are released when reasoning finishes. */ + @RequiresEdt + private fun releaseBody(): Boolean { + if (!parts.bodyCreated()) return false + val detached = discardBody() + Disposer.dispose(parts.md(openFile, openUrl)) + parts.reset() + registered = false + return detached + } + + @RequiresEdt + override fun userToggled() { + pinned = true + } + @RequiresEdt override fun appendDelta(delta: String) { if (delta.isEmpty()) return @@ -150,6 +175,15 @@ class ReasoningView( @RequiresEdt internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + if (isExpanded()) return null + val text = source.takeIf { it.isNotBlank() } ?: return null + return HeaderPopupRequest(row, build = { buildPopupBody(text) }) { + Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "part" to "reasoning")) + } + } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style @@ -235,6 +269,38 @@ class ReasoningView( Disposer.register(this, md) } + @RequiresEdt + private fun buildPopupBody(text: String): HeaderPopupBody { + val md = MdViewFactory.create(style, null).apply { + addLinkListener { openSessionLink(it, openFile, openUrl) } + } + md.applyStyle(style) + md.font = style.smallEditorFont.deriveFont(Font.ITALIC) + md.codeFont = style.editorFamily + md.foreground = UiStyle.Colors.weak() + md.background = style.editorBackground + md.component.border = JBUI.Borders.empty() + md.set(text) + val panel = TrackPanel().apply { + isOpaque = true + background = style.editorBackground + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), + ) + add(md.component, BorderLayout.CENTER) + } + val scroll = JBScrollPane(panel).apply { + border = JBUI.Borders.empty() + isOpaque = true + background = style.editorBackground + viewport.background = style.editorBackground + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + return HeaderPopupBody(scroll, md, style.editorBackground) + } + private fun bodyMaxHeight(): Int { if (!parts.bodyCreated()) return 0 val md = md @@ -280,6 +346,10 @@ class ReasoningParts( fun bodyCreated() = body != null + fun reset() { + body = null + } + fun md(openFile: SessionFileOpener, openUrl: (String) -> Unit): MdView = body(openFile, openUrl).md fun scroll(openFile: SessionFileOpener, openUrl: (String) -> Unit): JBScrollPane = body(openFile, openUrl).scroll diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index df67cbbca67..6e252b4364f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -67,10 +67,13 @@ abstract class AbstractSessionPartView( if (!expandable || !arrow.isVisible) return val changed = toggleLocal() if (!changed) return + userToggled() syncArrow() refresh() } + protected open fun userToggled() {} + open fun expand(): Boolean { if (!expandable) return false if (isExpanded()) return false @@ -89,6 +92,15 @@ abstract class AbstractSessionPartView( protected fun bodyComponent(): JComponent = body() + /** Detaches and forgets the cached body so the next expansion builds a fresh one. */ + protected fun discardBody(): Boolean { + val item = body ?: return false + val attached = item.parent === this + if (attached) remove(item) + body = null + return attached + } + private fun toggleLocal(): Boolean { val fn = resize ?: return toggleBody() val expanded = isExpanded() 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 0b50d798269..6cfa9164eba 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 @@ -216,6 +216,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = val qText = makeText(q, UiStyle.Colors.weak(), false) qText.alignmentX = Component.LEFT_ALIGNMENT + qText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) row.add(qText) val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty() 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 eb6d0b329a0..097a7eed9a7 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 @@ -490,6 +490,7 @@ class QuestionView( private fun buildCustomEditor(): SessionEditorTextField { val ed = SessionEditorTextField(project, selection = selection) ed.border = JBUI.Borders.empty() + ed.setFontInheritedFromLAF(false) ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) ed.setShowPlaceholderWhenFocused(true) ed.setOneLineMode(false) @@ -502,6 +503,7 @@ class QuestionView( ex.scrollPane.background = style.editorScheme.defaultBackground ex.scrollPane.viewport.background = style.editorScheme.defaultBackground ex.settings.isUseSoftWraps = true + ex.settings.isPaintSoftWraps = false ex.settings.isAdditionalPageAtBottom = false ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt index 7005a6eda05..63e2317606b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -22,7 +22,7 @@ class TodoListPanel( todos: List = emptyList(), private var before: Int = 0, private var after: Int = 0, -) : Stack(StackAxis.VERTICAL) { +) : Stack(StackAxis.VERTICAL, JBUI.scale(SessionUiStyle.View.Layout.GAP)) { private var items = todos private var style = SessionEditorStyle.current() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index fd1db6ec170..a08b54a8a7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -25,8 +25,6 @@ import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI -import java.awt.BorderLayout -import java.awt.Container import java.awt.Dimension import javax.swing.JComponent import javax.swing.JPanel @@ -196,7 +194,6 @@ class ShellToolView( MdCodeBlockFactory.default( MdCodeBlockOptions( border = MdCodeBlockBorder.None, - maxLines = SessionUiStyle.View.Popup.MAX_LINES, verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editorOnly = true, ), @@ -211,7 +208,7 @@ class ShellToolView( md.component.border = JBUI.Borders.empty() md.set(popupMd(formatCommand(cmd))) padPopup(md.component) - return HeaderPopupBody(PopupPanel(md.component), md, style.editorBackground) + return HeaderPopupBody(md.component, md, style.editorBackground) } override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" @@ -237,37 +234,6 @@ private fun padPopup(root: JComponent) { private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad) -private class PopupPanel(child: JComponent) : JPanel(BorderLayout()) { - init { - // Transparent so the balloon fill (editor background) shows uniformly behind the content. - isOpaque = false - add(child, BorderLayout.CENTER) - } - - override fun getPreferredSize(): Dimension { - // The markdown code block reports a preferred width of 0 so transcript layout can stretch it. - // A balloon has no such constraint, so derive the natural content width and cap it instead. - val size = super.getPreferredSize() - val width = contentWidth(this).coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) - return Dimension(maxOf(width, size.width), size.height) - } -} - -private fun contentWidth(root: Container): Int { - var max = 0 - for (child in root.components) { - if (child is JBScrollPane) { - val view = child.viewport.view as? JComponent - val content = view?.preferredSize?.width ?: 0 - val insets = child.insets - val viewport = child.viewportBorder?.getBorderInsets(child) ?: JBUI.emptyInsets() - max = maxOf(max, content + insets.left + insets.right + viewport.left + viewport.right) - } - if (child is Container) max = maxOf(max, contentWidth(child)) - } - return max -} - class ShellHolder( private val tool: Tool, private val selection: SessionSelection?, @@ -339,7 +305,7 @@ class ShellBody( private fun styleShell() { val root = md.component as? JPanel ?: return root.components.filterIsInstance().forEach { - it.border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)) + it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING) } } 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 index 520aa26ae71..63fa8589d67 100644 --- 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 @@ -331,12 +331,11 @@ internal class LoggedInProfileUi( @RequiresEdt private fun applyOrganizations(profile: ProfileDto) { val orgs = profile.organizations - val keys: List> = listOf(null to KiloBundle.message("profile.personalAccount")) + + val personal = profile.hasPersonalAccount + val keys: List> = (if (personal) listOf(null to KiloBundle.message("profile.personalAccount")) else emptyList()) + orgs.map { it.id to it.name } - val target = profile.currentOrgId - ?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) } - ?: 0 + val target = keys.indexOfFirst { it.first == profile.currentOrgId }.takeIf { it >= 0 } ?: 0 currentOrgId = profile.currentOrgId 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 index 6324247efa3..e71ce3b7e33 100644 --- 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 @@ -181,7 +181,7 @@ internal class LoggedOutProfileUi( } private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { - status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING + status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.DOWNLOADING || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING status == KiloAppStatusDto.ERROR -> OutMode.APP_ERROR login is LoginState.Initiating -> OutMode.INITIATING login is LoginState.Pending -> OutMode.AUTH 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 index 8e942bdd99d..eaeed04cd66 100644 --- 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 @@ -163,7 +163,7 @@ internal class ProfileUi( 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 || s == KiloAppStatusDto.MIGRATION_REQUIRED + val transientLoad = s == KiloAppStatusDto.DOWNLOADING || s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING || s == KiloAppStatusDto.MIGRATION_REQUIRED if (transientLoad && shown == Card.LOGGED_IN) return Card.LOGGED_IN return when { s == KiloAppStatusDto.DISCONNECTED || transientLoad -> Card.LOGGED_OUT 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 ecb2986f963..1d913645b23 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 @@ -105,6 +105,9 @@ + + @@ -114,9 +117,11 @@ - + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index c64398d56ec..3009e298b6f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,6 +1,8 @@ common.delete=Delete session.connection.connecting=Loading... +session.connection.downloading=Downloading Kilo Core… {0}% +session.connection.downloading.version=Downloading Kilo Core v{0} ({1})… {2}% session.connection.error.app=Connection failed session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error @@ -86,7 +88,8 @@ session.status.searching.codebase=Searching codebase… session.status.searching.web=Searching web… session.status.editing=Making edits… session.status.commands=Running commands… -session.status.retry=Retrying connection... +session.status.retry=Retrying… +session.status.retry.attempt={0} (attempt {1}) session.status.offline=Connection offline session.part.reasoning=Reasoning @@ -279,7 +282,7 @@ action.Kilo.ToolWindowToolbar.text=Kilo Toolbar settings.kilo.displayName=Kilo Code settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings. settings.cli.unavailable.title=Kilo Code is not connected -settings.cli.unavailable.message=Settings are available after Kilo Code connects to the CLI. If this does not resolve, restart the IDE or the Kilo CLI and try again. +settings.cli.unavailable.message=Settings are available after Kilo Code connects to Core. If this does not resolve, restart the IDE or Kilo Core and try again. settings.models.displayName=Models settings.providers.displayName=Providers settings.agentBehavior.displayName=Agent Behavior @@ -536,14 +539,17 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions action.Kilo.Restart.text=Restart -action.Kilo.Restart.cli.text=Restart CLI -action.Kilo.Restart.description=Kill and restart the CLI process +action.Kilo.Restart.cli.text=Restart Core +action.Kilo.Restart.description=Kill and restart the Core process action.Kilo.Reinstall.text=Reinstall -action.Kilo.Reinstall.cli.text=Reinstall CLI -action.Kilo.Reinstall.description=Re-extract the CLI binary and restart +action.Kilo.Reinstall.cli.text=Reinstall Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.text=Core v{0} • Architecture: {1} +action.Kilo.CoreInfo.loading=Core details loading... +action.Kilo.CoreInfo.description=Kilo Core version and architecture action.Kilo.Session.Open.text=Open action.Kilo.Session.Open.description=Open the selected session action.Kilo.Session.Rename.text=Rename diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 080f53bf16d..54a06c17255 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=إيقاف جلسة Kilo الحالية action.Kilo.SettingsGroup.text=الإعدادات action.Kilo.SettingsGroup.description=إعدادات Kilo Code action.Kilo.Restart.text=إعادة تشغيل Kilo -action.Kilo.Restart.cli.text=إعادة تشغيل CLI -action.Kilo.Restart.description=إنهاء عملية CLI وإعادة تشغيلها +action.Kilo.Restart.cli.text=إعادة تشغيل Core +action.Kilo.Restart.description=إنهاء عملية Core وإعادة تشغيلها action.Kilo.Reinstall.text=إعادة تثبيت Kilo -action.Kilo.Reinstall.cli.text=إعادة تثبيت CLI -action.Kilo.Reinstall.description=استخراج CLI مجدداً وإعادة التشغيل +action.Kilo.Reinstall.cli.text=إعادة تثبيت Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=فتح action.Kilo.Session.Open.description=فتح الجلسة المحددة action.Kilo.Session.Rename.text=إعادة تسمية @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index a68d6dd079b..8589d3cc662 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Zaustavi trenutnu Kilo sesiju action.Kilo.SettingsGroup.text=Postavke action.Kilo.SettingsGroup.description=Postavke Kilo Code action.Kilo.Restart.text=Ponovo pokrenite Kilo -action.Kilo.Restart.cli.text=Ponovo pokrenite CLI -action.Kilo.Restart.description=Ugasite i ponovo pokrenite CLI proces +action.Kilo.Restart.cli.text=Ponovo pokrenite Core +action.Kilo.Restart.description=Ugasite i ponovo pokrenite Core proces action.Kilo.Reinstall.text=Ponovo instalirajte Kilo -action.Kilo.Reinstall.cli.text=Ponovo instalirajte CLI -action.Kilo.Reinstall.description=Ponovo raspakirajte CLI binarnu datoteku i ponovo pokrenite +action.Kilo.Reinstall.cli.text=Ponovo instalirajte Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Otvori action.Kilo.Session.Open.description=Otvorite odabranu sesiju action.Kilo.Session.Rename.text=Preimenuj @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index d2d1ed7e20c..b14a7d7f371 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Stop den aktuelle Kilo-session action.Kilo.SettingsGroup.text=Indstillinger action.Kilo.SettingsGroup.description=Kilo Code-indstillinger action.Kilo.Restart.text=Genstart Kilo -action.Kilo.Restart.cli.text=Genstart CLI -action.Kilo.Restart.description=Afslut og genstart CLI-processen +action.Kilo.Restart.cli.text=Genstart Core +action.Kilo.Restart.description=Afslut og genstart Core-processen action.Kilo.Reinstall.text=Geninstaller Kilo -action.Kilo.Reinstall.cli.text=Geninstaller CLI -action.Kilo.Reinstall.description=Udpak CLI-binærfilen igen og genstart +action.Kilo.Reinstall.cli.text=Geninstaller Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Åbn action.Kilo.Session.Open.description=Åbn den valgte session action.Kilo.Session.Rename.text=Omdøb @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 7a7a691fefb..508372e4255 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Aktuelle Kilo-Sitzung stoppen action.Kilo.SettingsGroup.text=Einstellungen action.Kilo.SettingsGroup.description=Kilo Code Einstellungen action.Kilo.Restart.text=Kilo neu starten -action.Kilo.Restart.cli.text=CLI neu starten -action.Kilo.Restart.description=CLI-Prozess beenden und neu starten +action.Kilo.Restart.cli.text=Core neu starten +action.Kilo.Restart.description=Core-Prozess beenden und neu starten action.Kilo.Reinstall.text=Kilo neu installieren -action.Kilo.Reinstall.cli.text=CLI neu installieren -action.Kilo.Reinstall.description=CLI-Binärdatei neu extrahieren und neu starten +action.Kilo.Reinstall.cli.text=Core neu installieren +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Öffnen action.Kilo.Session.Open.description=Ausgewählte Sitzung öffnen action.Kilo.Session.Rename.text=Umbenennen @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index baddfdc2248..eaae1f4cbf9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Detener la sesión actual de Kilo action.Kilo.SettingsGroup.text=Configuración action.Kilo.SettingsGroup.description=Configuración de Kilo Code action.Kilo.Restart.text=Reiniciar Kilo -action.Kilo.Restart.cli.text=Reiniciar CLI -action.Kilo.Restart.description=Terminar y reiniciar el proceso CLI +action.Kilo.Restart.cli.text=Reiniciar Core +action.Kilo.Restart.description=Terminar y reiniciar el proceso Core action.Kilo.Reinstall.text=Reinstalar Kilo -action.Kilo.Reinstall.cli.text=Reinstalar CLI -action.Kilo.Reinstall.description=Reextraer el binario CLI y reiniciar +action.Kilo.Reinstall.cli.text=Reinstalar Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir la sesión seleccionada action.Kilo.Session.Rename.text=Renombrar @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 1d938db28e5..ba0795ca4e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Arrêter la session Kilo actuelle action.Kilo.SettingsGroup.text=Paramètres action.Kilo.SettingsGroup.description=Paramètres de Kilo Code action.Kilo.Restart.text=Redémarrer Kilo -action.Kilo.Restart.cli.text=Redémarrer CLI -action.Kilo.Restart.description=Tuer et redémarrer le processus CLI +action.Kilo.Restart.cli.text=Redémarrer Core +action.Kilo.Restart.description=Tuer et redémarrer le processus Core action.Kilo.Reinstall.text=Réinstaller Kilo -action.Kilo.Reinstall.cli.text=Réinstaller CLI -action.Kilo.Reinstall.description=Réextraire le binaire CLI et redémarrer +action.Kilo.Reinstall.cli.text=Réinstaller Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Ouvrir action.Kilo.Session.Open.description=Ouvrir la session sélectionnée action.Kilo.Session.Rename.text=Renommer @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 3912f106aaa..360e3124981 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=現在のKiloセッションを停止 action.Kilo.SettingsGroup.text=設定 action.Kilo.SettingsGroup.description=Kilo Codeの設定 action.Kilo.Restart.text=Kiloを再起動 -action.Kilo.Restart.cli.text=CLIを再起動 -action.Kilo.Restart.description=CLIプロセスを終了して再起動 +action.Kilo.Restart.cli.text=Coreを再起動 +action.Kilo.Restart.description=Coreプロセスを終了して再起動 action.Kilo.Reinstall.text=Kiloを再インストール -action.Kilo.Reinstall.cli.text=CLIを再インストール -action.Kilo.Reinstall.description=CLIバイナリを再展開して再起動 +action.Kilo.Reinstall.cli.text=Coreを再インストール +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=開く action.Kilo.Session.Open.description=選択したセッションを開く action.Kilo.Session.Rename.text=変名 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 82d758dce12..970ddaf16d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=현재 Kilo 세션 정지 action.Kilo.SettingsGroup.text=설정 action.Kilo.SettingsGroup.description=Kilo Code 설정 action.Kilo.Restart.text=Kilo 재시작 -action.Kilo.Restart.cli.text=CLI 재시작 -action.Kilo.Restart.description=CLI 프로세스 종료 후 재시작 +action.Kilo.Restart.cli.text=Core 재시작 +action.Kilo.Restart.description=Core 프로세스 종료 후 재시작 action.Kilo.Reinstall.text=Kilo 재설치 -action.Kilo.Reinstall.cli.text=CLI 재설치 -action.Kilo.Reinstall.description=CLI 바이너리 재추출 후 재시작 +action.Kilo.Reinstall.cli.text=Core 재설치 +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=열기 action.Kilo.Session.Open.description=선택한 세션 열기 action.Kilo.Session.Rename.text=이름 바꾸기 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 72e2813f190..7e19e9aee2c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Huidige Kilo-sessie stoppen action.Kilo.SettingsGroup.text=Instellingen action.Kilo.SettingsGroup.description=Kilo Code-instellingen action.Kilo.Restart.text=Kilo herstarten -action.Kilo.Restart.cli.text=CLI herstarten -action.Kilo.Restart.description=CLI-proces beëindigen en herstarten +action.Kilo.Restart.cli.text=Core herstarten +action.Kilo.Restart.description=Core-proces beëindigen en herstarten action.Kilo.Reinstall.text=Kilo herinstalleren -action.Kilo.Reinstall.cli.text=CLI herinstalleren -action.Kilo.Reinstall.description=CLI-binair opnieuw uitpakken en herstarten +action.Kilo.Reinstall.cli.text=Core herinstalleren +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Openen action.Kilo.Session.Open.description=Geselecteerde sessie openen action.Kilo.Session.Rename.text=Hernoemen @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 77052f93672..d55e8a0166a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Stopp gjeldende Kilo-økt action.Kilo.SettingsGroup.text=Innstillinger action.Kilo.SettingsGroup.description=Kilo Code-innstillinger action.Kilo.Restart.text=Start Kilo på nytt -action.Kilo.Restart.cli.text=Start CLI på nytt -action.Kilo.Restart.description=Avslutt og start CLI-prosessen på nytt +action.Kilo.Restart.cli.text=Start Core på nytt +action.Kilo.Restart.description=Avslutt og start Core-prosessen på nytt action.Kilo.Reinstall.text=Installer Kilo på nytt -action.Kilo.Reinstall.cli.text=Installer CLI på nytt -action.Kilo.Reinstall.description=Pakk ut CLI-binærfilen på nytt og start på nytt +action.Kilo.Reinstall.cli.text=Installer Core på nytt +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Åpne action.Kilo.Session.Open.description=Åpne valgt økt action.Kilo.Session.Rename.text=Gi nytt navn @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 171ac2d8241..dfa0f2442d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Zatrzymaj bieżącą sesję Kilo action.Kilo.SettingsGroup.text=Ustawienia action.Kilo.SettingsGroup.description=Ustawienia Kilo Code action.Kilo.Restart.text=Uruchom ponownie Kilo -action.Kilo.Restart.cli.text=Uruchom ponownie CLI -action.Kilo.Restart.description=Zakończ i uruchom ponownie proces CLI +action.Kilo.Restart.cli.text=Uruchom ponownie Core +action.Kilo.Restart.description=Zakończ i uruchom ponownie proces Core action.Kilo.Reinstall.text=Zainstaluj ponownie Kilo -action.Kilo.Reinstall.cli.text=Zainstaluj ponownie CLI -action.Kilo.Reinstall.description=Ponownie wyodrębnij plik binarny CLI i uruchom ponownie +action.Kilo.Reinstall.cli.text=Zainstaluj ponownie Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Otwórz action.Kilo.Session.Open.description=Otwórz wybraną sesję action.Kilo.Session.Rename.text=Zmień nazwę @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index cec8cecbdd2..aac69eaae96 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Parar a sessão atual do Kilo action.Kilo.SettingsGroup.text=Configurações action.Kilo.SettingsGroup.description=Configurações do Kilo Code action.Kilo.Restart.text=Reiniciar Kilo -action.Kilo.Restart.cli.text=Reiniciar CLI -action.Kilo.Restart.description=Encerrar e reiniciar o processo CLI +action.Kilo.Restart.cli.text=Reiniciar Core +action.Kilo.Restart.description=Encerrar e reiniciar o processo Core action.Kilo.Reinstall.text=Reinstalar Kilo -action.Kilo.Reinstall.cli.text=Reinstalar CLI -action.Kilo.Reinstall.description=Reextrair o binário CLI e reiniciar +action.Kilo.Reinstall.cli.text=Reinstalar Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir a sessão selecionada action.Kilo.Session.Rename.text=Renomear @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index f9528d1e32a..edf8aea4b50 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Остановить текущую сесс action.Kilo.SettingsGroup.text=Настройки action.Kilo.SettingsGroup.description=Настройки Kilo Code action.Kilo.Restart.text=Перезапустить Kilo -action.Kilo.Restart.cli.text=Перезапустить CLI -action.Kilo.Restart.description=Завершить и перезапустить CLI-процесс +action.Kilo.Restart.cli.text=Перезапустить Core +action.Kilo.Restart.description=Завершить и перезапустить Core-процесс action.Kilo.Reinstall.text=Переустановить Kilo -action.Kilo.Reinstall.cli.text=Переустановить CLI -action.Kilo.Reinstall.description=Переизвлечь CLI-бинарник и перезапустить +action.Kilo.Reinstall.cli.text=Переустановить Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Открыть action.Kilo.Session.Open.description=Открыть выбранную сессию action.Kilo.Session.Rename.text=Переименовать @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index c07d2fe693a..192fbafa392 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=หยุดเซสชัน Kilo ปั action.Kilo.SettingsGroup.text=การตั้งค่า action.Kilo.SettingsGroup.description=การตั้งค่า Kilo Code action.Kilo.Restart.text=รีสตาร์ต Kilo -action.Kilo.Restart.cli.text=รีสตาร์ต CLI -action.Kilo.Restart.description=สิ้นสุดและรีสตาร์ตกระบวนการ CLI +action.Kilo.Restart.cli.text=รีสตาร์ต Core +action.Kilo.Restart.description=สิ้นสุดและรีสตาร์ตกระบวนการ Core action.Kilo.Reinstall.text=ติดตั้ง Kilo ใหม่ -action.Kilo.Reinstall.cli.text=ติดตั้ง CLI ใหม่ -action.Kilo.Reinstall.description=แตกไฟล์ไบนารี CLI อีกครั้งและรีสตาร์ต +action.Kilo.Reinstall.cli.text=ติดตั้ง Core ใหม่ +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=เปิด action.Kilo.Session.Open.description=เปิดเซสชันที่เลือก action.Kilo.Session.Rename.text=เปลี่ยนชื่อ @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index ed9646acac2..3976303b098 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -139,11 +139,11 @@ action.Kilo.StopSession.description=Geçerli Kilo oturumunu durdur action.Kilo.SettingsGroup.text=Ayarlar action.Kilo.SettingsGroup.description=Kilo Code ayarları action.Kilo.Restart.text=Kilo’yu yeniden başlat -action.Kilo.Restart.cli.text=CLI’yi yeniden başlat -action.Kilo.Restart.description=CLI sürecini sonlandır ve yeniden başlat +action.Kilo.Restart.cli.text=Core’yi yeniden başlat +action.Kilo.Restart.description=Core sürecini sonlandır ve yeniden başlat action.Kilo.Reinstall.text=Kilo’yu yeniden yükle -action.Kilo.Reinstall.cli.text=CLI’yi yeniden yükle -action.Kilo.Reinstall.description=CLI ikili dosyasını yeniden çıkar ve yeniden başlat +action.Kilo.Reinstall.cli.text=Core’yi yeniden yükle +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Aç action.Kilo.Session.Open.description=Seçilen oturumu aç action.Kilo.Session.Rename.text=Yeniden adlandır @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 7b6911cf734..6594c293afb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=Зупинити поточну сесію K action.Kilo.SettingsGroup.text=Налаштування action.Kilo.SettingsGroup.description=Налаштування Kilo Code action.Kilo.Restart.text=Перезапустити Kilo -action.Kilo.Restart.cli.text=Перезапустити CLI -action.Kilo.Restart.description=Завершити і перезапустити CLI-процес +action.Kilo.Restart.cli.text=Перезапустити Core +action.Kilo.Restart.description=Завершити і перезапустити Core-процес action.Kilo.Reinstall.text=Перевстановити Kilo -action.Kilo.Reinstall.cli.text=Перевстановити CLI -action.Kilo.Reinstall.description=Перерозпакувати CLI-бінарний файл і перезапустити +action.Kilo.Reinstall.cli.text=Перевстановити Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=Відкрити action.Kilo.Session.Open.description=Відкрити вибрану сесію action.Kilo.Session.Rename.text=Перейменувати @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index eedfaf9fa9c..959b9c8c9d3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=停止当前 Kilo 会话 action.Kilo.SettingsGroup.text=设置 action.Kilo.SettingsGroup.description=Kilo Code 设置 action.Kilo.Restart.text=重启 Kilo -action.Kilo.Restart.cli.text=重启 CLI -action.Kilo.Restart.description=终止并重启 CLI 进程 +action.Kilo.Restart.cli.text=重启 Core +action.Kilo.Restart.description=终止并重启 Core 进程 action.Kilo.Reinstall.text=重新安装 Kilo -action.Kilo.Reinstall.cli.text=重新安装 CLI -action.Kilo.Reinstall.description=重新解压 CLI 二进制文件并重启 +action.Kilo.Reinstall.cli.text=重新安装 Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=打开 action.Kilo.Session.Open.description=打开所选会话 action.Kilo.Session.Rename.text=重命名 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 6021a8790a1..b9e0268c877 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -134,11 +134,11 @@ action.Kilo.StopSession.description=停止目前的 Kilo 工作階段 action.Kilo.SettingsGroup.text=設定 action.Kilo.SettingsGroup.description=Kilo Code 設定 action.Kilo.Restart.text=重啟 Kilo -action.Kilo.Restart.cli.text=重啟 CLI -action.Kilo.Restart.description=終止並重啟 CLI 程序 +action.Kilo.Restart.cli.text=重啟 Core +action.Kilo.Restart.description=終止並重啟 Core 程序 action.Kilo.Reinstall.text=重新安裝 Kilo -action.Kilo.Reinstall.cli.text=重新安裝 CLI -action.Kilo.Reinstall.description=重新解壓縮 CLI 二進位檔案並重啟 +action.Kilo.Reinstall.cli.text=重新安裝 Core +action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.Session.Open.text=開啟 action.Kilo.Session.Open.description=開啟所選工作階段 action.Kilo.Session.Rename.text=重命名 @@ -310,8 +310,8 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file -action.Kilo.CliGroup.text=CLI -action.Kilo.CliGroup.description=Kilo CLI actions +action.Kilo.CliGroup.text=Core +action.Kilo.CliGroup.description=Kilo Core actions settings.agentBehavior.agents.import.title=Import Agent Definition settings.agentBehavior.agents.import.description=Choose a .agent.json file exported from Kilo Code. settings.agentBehavior.agents.import.progress=Importing agent definition... diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 14b0d61e4e6..8629fa1c762 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -1,8 +1,10 @@ package ai.kilocode.client.actions +import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -30,11 +32,18 @@ import kotlinx.coroutines.withTimeout class KiloRecoveryActionsTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var appRpc: FakeAppRpcApi override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) rpc = FakeWorkspaceRpcApi() + appRpc = FakeAppRpcApi() + ApplicationManager.getApplication().replaceService( + KiloAppService::class.java, + KiloAppService(scope, appRpc), + testRootDisposable, + ) ApplicationManager.getApplication().replaceService( KiloWorkspaceService::class.java, KiloWorkspaceService(scope, rpc), @@ -68,38 +77,55 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled) } - fun `test restart action adds cli suffix in connection retry popup`() { + fun `test restart action adds core suffix in connection retry popup`() { val action = RestartKiloAction() val event = event(action, place = KiloActionPlaces.connectionRetryPopup()) update(action, event) - assertEquals("Restart CLI", event.presentation.text) + assertEquals("Restart Core", event.presentation.text) } - fun `test reinstall action adds cli suffix in connection retry popup`() { + fun `test reinstall action adds core suffix in connection retry popup`() { val action = ReinstallKiloAction() val event = event(action, place = KiloActionPlaces.connectionRetryPopup()) update(action, event) - assertEquals("Reinstall CLI", event.presentation.text) + assertEquals("Reinstall Core", event.presentation.text) } - fun `test cli group has visible menu text`() { + fun `test core group has visible menu text and info action`() { val xml = requireNotNull(javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml")) .bufferedReader() .use { it.readText() } - assertTrue(xml.contains("")) + assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertTrue(xml.contains("")) + assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertFalse(xml.contains("")) } + fun `test core info action shows version and architecture`() { + appRpc.cliVersion = "1.2.3" + appRpc.cliPlatform = "darwin-arm64" + ApplicationManager.getApplication().executeOnPooledThread { + runBlocking { app().coreInfo() } + }.get() + val action = CoreInfoAction() + val event = event(action) + + update(action, event) + + assertFalse(event.presentation.isEnabled) + assertTrue(event.presentation.isVisible) + assertEquals("Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) + } + fun `test local config action says open when target exists`() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" @@ -311,6 +337,8 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { private fun service(): KiloWorkspaceService = ApplicationManager.getApplication().getService(KiloWorkspaceService::class.java) + private fun app(): KiloAppService = ApplicationManager.getApplication().getService(KiloAppService::class.java) + private fun cacheGlobal(target: ConfigTargetDto) { val field = KiloWorkspaceService::class.java.getDeclaredField("globalConfig") field.isAccessible = true diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt index abfe104f09f..35086670ee4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAppServiceTest.kt @@ -7,11 +7,14 @@ import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlinx.coroutines.withTimeout /** * Service-level tests for [KiloAppService] profile/login/logout/org operations. @@ -49,6 +52,47 @@ class KiloAppServiceTest : BasePlatformTestCase() { currentOrgId: String? = null, ) = ProfileDto(email = email, name = name, organizations = orgs, balance = balance, currentOrgId = currentOrgId) + private suspend fun waitUntil(done: () -> Boolean) { + withTimeout(5_000) { + while (!done()) yield() + } + } + + fun `test fetchCoreInfoAsync dedupes in flight requests`() = runBlocking(Dispatchers.Default) { + rpc.cliInfoGate = CompletableDeferred() + val seen = mutableListOf() + + app.fetchCoreInfoAsync { seen.add(it) } + app.fetchCoreInfoAsync { seen.add(it) } + waitUntil { rpc.cliVersionCalls == 1 } + + assertEquals(1, rpc.cliVersionCalls) + assertEquals(0, rpc.cliPlatformCalls) + rpc.cliInfoGate!!.complete(Unit) + waitUntil { seen.size == 2 } + + assertEquals(1, rpc.cliVersionCalls) + assertEquals(1, rpc.cliPlatformCalls) + assertEquals(listOf(CoreInfo("1.0.0", "darwin-arm64"), CoreInfo("1.0.0", "darwin-arm64")), seen) + } + + fun `test fetchCoreInfoAsync retries after failure`() = runBlocking(Dispatchers.Default) { + val seen = mutableListOf() + rpc.cliInfoError = RuntimeException("core failed") + + app.fetchCoreInfoAsync { seen.add(it) } + waitUntil { seen.size == 1 } + + assertNull(seen.single()) + assertNull(app.core) + rpc.cliInfoError = null + app.fetchCoreInfoAsync { seen.add(it) } + waitUntil { seen.size == 2 } + + assertEquals(CoreInfo("1.0.0", "darwin-arm64"), seen.last()) + assertEquals(2, rpc.cliVersionCalls) + } + // ------ refreshProfile ------ fun `test refreshProfile updates app state profile on success`() = runBlocking(Dispatchers.Default) { 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 e470948dea4..4e89fe1f5cf 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 @@ -15,6 +15,7 @@ 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.style.SessionEditorStyle import ai.kilocode.client.session.ui.header.SessionHeaderPanel import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerEvent @@ -45,6 +46,21 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertFalse(root.blocker.isVisible) } + fun `test session surfaces use session background from initial render`() { + val bg = SessionEditorStyle.current().editorBackground + val root = find(ui) + val pane = scrollComponent() as JBScrollPane + + assertEquals(bg, ui.background) + assertEquals(bg, root.content.background) + assertEquals(bg, pane.background) + assertEquals(bg, pane.viewport.background) + + showMessages() + + assertEquals(bg, find(ui).background) + } + fun `test prompt is docked and connection is overlaid`() { val root = find(ui) val connection = find(ui) @@ -296,25 +312,27 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(find(ui), scrollView()) } - fun `test retry status renders in loading panel instead of message body`() { + fun `test retry status keeps transcript and shows message in footer`() { rpc.history.addAll(history(1)) ui = newUi(id = "ses_test") settle() - controller().model.setState(SessionState.Retry("Cannot connect to API", attempt = 2, next = 1_234L)) + controller().model.setState(SessionState.Retry("The usage limit has been reached", attempt = 4, next = 0L)) layout() - val panel = find(ui) + val panel = find(ui) assertSame(panel, scrollView()) - assertEquals("Cannot connect to API", panel.labelText()) + assertTrue(panel.progress.isVisible) + assertEquals("The usage limit has been reached (attempt 4)", panel.progress.labelText()) controller().model.setState(SessionState.Idle) layout() - assertSame(find(ui), scrollView()) + assertSame(panel, scrollView()) + assertFalse(panel.progress.isVisible) } - fun `test offline status renders in loading panel with fallback`() { + fun `test offline status keeps transcript and shows message in footer`() { rpc.history.addAll(history(1)) ui = newUi(id = "ses_test") settle() @@ -322,9 +340,65 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.Offline("", requestId = "req1")) layout() - val panel = find(ui) + val panel = find(ui) assertSame(panel, scrollView()) - assertEquals("Connection offline", panel.labelText()) + assertTrue(panel.progress.isVisible) + assertEquals("Connection offline", panel.progress.labelText()) + } + + fun `test busy progress footer uses transcript foreground`() { + rpc.history.addAll(history(1)) + ui = newUi(id = "ses_test") + settle() + + controller().model.setState(SessionState.Busy("Considering next steps...")) + layout() + + val panel = find(ui) + assertTrue(panel.progress.isVisible) + assertEquals(SessionEditorStyle.current().editorForeground, panel.progress.labelForeground()) + } + + fun `test retry before transcript shows loading panel`() { + ui = newUi(displayMs = 1_000) + + controller().model.setState(SessionState.Retry("The usage limit has been reached", attempt = 4, next = 0L)) + layout() + + assertSame(find(ui), scrollView()) + } + + fun `test retry offline churn retains transcript panel and footer`() { + rpc.history.addAll(history(1)) + ui = newUi(id = "ses_test") + settle() + + val panel = find(ui) + val progress = panel.progress + val count = panel.componentCount + + repeat(100) { i -> + controller().model.setState(SessionState.Retry("Rate limited", attempt = i + 1, next = 0L)) + layout() + assertSame(panel, scrollView()) + assertSame(progress, panel.progress) + assertEquals(count, panel.componentCount) + assertTrue(panel.progress.isVisible) + + controller().model.setState(SessionState.Offline("Connection offline", requestId = "req$i")) + layout() + assertSame(panel, scrollView()) + assertSame(progress, panel.progress) + assertEquals(count, panel.componentCount) + assertTrue(panel.progress.isVisible) + + controller().model.setState(SessionState.Idle) + layout() + assertSame(panel, scrollView()) + assertSame(progress, panel.progress) + assertEquals(count, panel.componentCount) + assertFalse(panel.progress.isVisible) + } } fun `test empty explicit session id shows message body`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt index dbe7c98d4ea..df346f15907 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.StepFinish import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.SessionStatusDto /** * Verifies that: @@ -158,4 +159,51 @@ class ProgressTrackingTest : SessionControllerTestBase() { val text = (last!!.state as SessionState.Busy).text assertTrue(text.contains("Running", ignoreCase = true)) } + + fun `test PartUpdated clears Retry back to Busy`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("retry", "The usage limit has been reached", attempt = 4, next = 0L), + )) + assertTrue(m.model.state is SessionState.Retry) + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "text", text = "hello"))) + + assertSession( + """ + assistant#msg1 + text#p1: + hello + + [code] [kilo/gpt-5] [busy] [writing response] + """, + m, + ) + } + + fun `test PartUpdated clears Offline back to Busy`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("offline", "Connection offline", requestID = "req1"), + )) + assertTrue(m.model.state is SessionState.Offline) + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "tool", tool = "bash"))) + + assertSession( + """ + assistant#msg1 + tool#p1 bash [PENDING] + + [code] [kilo/gpt-5] [busy] [running commands] + """, + m, + ) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt index 3ccfb9de9a7..48fda098476 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -9,6 +9,9 @@ import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.SessionRef import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.layout.Align import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -19,12 +22,18 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import java.awt.BorderLayout import java.awt.Cursor import java.awt.event.KeyEvent import java.time.Instant @@ -34,6 +43,7 @@ import java.time.temporal.ChronoUnit import java.util.concurrent.atomic.AtomicInteger import javax.swing.JComponent import javax.swing.KeyStroke +import javax.swing.ScrollPaneConstants import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener @@ -200,6 +210,30 @@ class HistoryControllerTest : BasePlatformTestCase() { assertFalse(renderer.runningVisible()) } + fun `test history renderer uses trailing time with squeezable title`() { + val item = LocalHistoryItem(session("ses_1", "Long ".repeat(80))) + val controller = controller() + controller.local.replace(listOf(item)) + val renderer = LocalHistoryRenderer(controller.local) + val view = renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + val title = UIUtil.uiTraverser(view).filter(SimpleColoredComponent::class.java).firstOrNull() ?: error("missing title") + val head = title.parent + assertTrue(head.layout is BorderLayout) + assertSame(title, (head.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER)) + + val time = UIUtil.uiTraverser(view).filter(JBLabel::class.java) + .first { it.text == HistoryTime.relative(item) } + val main = time.parent + assertTrue(main.layout is BorderLayout) + assertSame(time, (main.layout as BorderLayout).getLayoutComponent(BorderLayout.EAST)) + + val row = main.parent as JComponent + val ins = row.border.getBorderInsets(row) + assertEquals(ins.left, ins.right) + assertEquals(UiStyle.Gap.lg(), ins.right) + } + fun `test history panel sync updates running badges`() { rpc.listed += session("ses_1", "Local One") val panel = HistoryPanel(parent, controller()) @@ -291,11 +325,29 @@ class HistoryControllerTest : BasePlatformTestCase() { assertEquals(1, panel.itemCount()) } + fun `test history panels do not scroll horizontally`() { + rpc.listed += session("ses_1", "Local ".repeat(80)) + rpc.cloud += cloud("cloud_1", "Cloud ".repeat(80)) + val panel = HistoryPanel(parent, controller()) + flush() + + assertNoHorizontalScroll(panel) + panel.clickCloud() + flush() + + assertNoHorizontalScroll(panel) + } + fun `test panel shows back button with tabs`() { val panel = HistoryPanel(parent, controller()) flush() assertEquals(KiloBundle.message("history.back"), panel.backText()) + val backs = UIUtil.uiTraverser(panel.component).filter(HoverIcon::class.java) + .filter { it.toolTipText == KiloBundle.message("history.back") } + .toList() + assertTrue(backs.isNotEmpty()) + assertTrue(backs.all { it.parent is Align }) panel.clickCloud() flush() @@ -854,6 +906,26 @@ class HistoryControllerTest : BasePlatformTestCase() { override fun openSession(ref: SessionRef) {} } + private fun assertNoHorizontalScroll(panel: HistoryPanel) { + assertEquals(0, panel.component.insets.right) + + val searches = UIUtil.uiTraverser(panel.component).filter(SearchTextField::class.java).toList() + assertTrue(searches.isNotEmpty()) + assertTrue(searches.all { inset(it.parent as JComponent) == UiStyle.Gap.lg() }) + + val scrolls = UIUtil.uiTraverser(panel.component).filter(JBScrollPane::class.java).toList() + .filter { it.viewport.view is JBList<*> } + assertTrue(scrolls.isNotEmpty()) + assertTrue(scrolls.all { it.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER }) + assertTrue(scrolls.all { it.viewportBorder.getBorderInsets(it).right == UiStyle.Gap.lg() }) + + val lists = UIUtil.uiTraverser(panel.component).filter(JBList::class.java).toList() + assertTrue(lists.isNotEmpty()) + assertTrue(lists.all { it.getScrollableTracksViewportWidth() }) + } + + private fun inset(component: JComponent) = component.border.getBorderInsets(component).right + private fun collect(controller: HistoryController): MutableList { val events = mutableListOf() val listener = object : ListDataListener { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index deb5a8d94e8..e7253e5867a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent import ai.kilocode.client.session.controller.SessionControllerTestBase @@ -35,6 +36,19 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.retryVisible()) } + fun `test downloading hides retry and details`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowDownloading(42, "1.2.3", "darwin-arm64")) + } + + assertTrue(panel.isVisible) + assertEquals(KiloBundle.message("session.connection.downloading.version", "1.2.3", "darwin-arm64", 42), panel.summaryText()) + assertEquals("", panel.detailsText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertFalse(panel.retryVisible()) + } + fun `test app error starts collapsed and expands details`() { edt { panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError( @@ -80,7 +94,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertEquals("Try again", panel.retryText()) } - fun `test retry popup group uses cli recovery actions`() { + fun `test retry popup group uses core recovery actions`() { edt { panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", null)) } @@ -90,9 +104,10 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.retryVisible()) assertEquals("Kilo.CliGroup", ConnectionPanel.CLI_GROUP_ID) - assertTrue(xml.contains("")) + assertTrue(xml.contains("")) assertTrue(xml.contains("")) assertTrue(xml.contains("")) + assertTrue(xml.contains("")) } fun `test ready warnings show collapsed banner with retry`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 7974709b08d..4cd7efa388a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel @@ -72,16 +73,61 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Writing response\u2026", panel.labelText()) } - fun `test panel hides on Retry`() { - model.setState(SessionState.Retry("Cannot connect to API", attempt = 2, next = 1_234L)) + fun `test panel shows on Retry with message and attempt`() { + model.setState(SessionState.Retry("The usage limit has been reached", attempt = 4, next = 0L)) - assertFalse(panel.isVisible) + assertTrue(panel.isVisible) + assertTrue(spinner().isVisible) + assertEquals("The usage limit has been reached (attempt 4)", panel.labelText()) } - fun `test panel hides on Offline`() { + fun `test panel shows on Offline`() { model.setState(SessionState.Offline("Computer appears offline", requestId = "req1")) - assertFalse(panel.isVisible) + assertTrue(panel.isVisible) + assertFalse(spinner().isVisible) + assertEquals("Computer appears offline", panel.labelText()) + } + + fun `test retry falls back to generic message when blank`() { + model.setState(SessionState.Retry("", attempt = 0, next = 0L)) + + assertTrue(panel.isVisible) + assertEquals(KiloBundle.message("session.status.retry"), panel.labelText()) + } + + fun `test retry without attempt omits attempt suffix`() { + model.setState(SessionState.Retry("Rate limited", attempt = 0, next = 0L)) + + assertTrue(panel.isVisible) + assertEquals("Rate limited", panel.labelText()) + } + + fun `test state churn retains footer components`() { + val count = panel.componentCount + val icon = panel.components[0] + val text = panel.components[1] + + repeat(500) { i -> + model.setState(SessionState.Busy("Thinking $i")) + model.setState(SessionState.Retry("Rate limited", attempt = i + 1, next = 0L)) + model.setState(SessionState.Offline("Computer appears offline", requestId = "req$i")) + model.setState(SessionState.Idle) + + assertEquals(count, panel.componentCount) + assertSame(icon, panel.components[0]) + assertSame(text, panel.components[1]) + } + } + + fun `test disposing parent removes model listener`() { + model.setState(SessionState.Busy("Thinking")) + Disposer.dispose(parent) + + model.setState(SessionState.Retry("Rate limited", attempt = 1, next = 0L)) + + assertEquals("Thinking", panel.labelText()) + parent = Disposer.newDisposable("test replacement") } fun `test panel hides on Error state`() { @@ -108,4 +154,6 @@ class ProgressPanelTest : BasePlatformTestCase() { always = emptyList(), meta = PermissionMeta(raw = emptyMap()), ) + + private fun spinner() = panel.components[0] } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index a4838926a4f..c1ae8e54c45 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -48,8 +48,10 @@ import com.intellij.openapi.command.undo.UndoManager import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider import com.intellij.openapi.editor.actions.PasteAction import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.fileTypes.PlainTextFileType @@ -57,8 +59,10 @@ import com.intellij.openapi.fileTypes.PlainTextLanguage import com.intellij.openapi.keymap.KeymapUtil import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService import com.intellij.ui.AnimatedIcon import com.intellij.ui.EditorTextField +import com.intellij.ui.EditorCustomization import com.intellij.ui.LanguageTextField import com.intellij.ui.components.JBLabel import com.intellij.util.Producer @@ -80,6 +84,7 @@ import java.awt.KeyboardFocusManager import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.awt.datatransfer.Transferable +import java.awt.event.FocusEvent import java.awt.event.MouseEvent import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream @@ -150,6 +155,38 @@ class PromptPanelTest : BasePlatformTestCase() { assertFalse(hasFloatingToolbar(editor.component)) } + fun `test prompt editor disables spellchecking`() { + var applied: EditorEx? = null + val provider = object : SpellCheckingEditorCustomizationProvider() { + override fun getDisabledCustomization(): EditorCustomization { + return EditorCustomization { ed -> applied = ed } + } + } + ApplicationManager.getApplication().replaceService( + SpellCheckingEditorCustomizationProvider::class.java, + provider, + testRootDisposable, + ) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + realize(panel, 260, 400) + val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + + assertSame(editor, applied) + } + + fun `test prompt editor horizontal insets use dedicated prompt inset`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + realize(panel, 260, 400) + val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane) + val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET) + + assertEquals(pad, ins.left) + assertEquals(pad, ins.right) + } + fun `test prompt focus outline follows editor focus`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 260, 400) @@ -161,10 +198,15 @@ class PromptPanelTest : BasePlatformTestCase() { val focus = TestFocusManager() KeyboardFocusManager.setCurrentKeyboardFocusManager(focus) try { + assertEquals(SessionUiStyle.View.Prompt.separator().rgb, paint(panel, panel.width / 2, 0).rgb) assertTrue(JBUI.CurrentTheme.Focus.focusColor().rgb != paint(panel, panel.width / 2, 1).rgb) focus.focus(editor.contentComponent) + editor.contentComponent.focusListeners.forEach { + it.focusGained(FocusEvent(editor.contentComponent, FocusEvent.FOCUS_GAINED)) + } + assertTrue(SessionUiStyle.View.Prompt.separator().rgb != paint(panel, panel.width / 2, 0).rgb) assertEquals(JBUI.CurrentTheme.Focus.focusColor().rgb, paint(panel, panel.width / 2, 1).rgb) } finally { KeyboardFocusManager.setCurrentKeyboardFocusManager(current) 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 13b9740f41a..ceecd76baa1 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 @@ -19,6 +19,7 @@ import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.MessageToolbar import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.TextView +import ai.kilocode.client.session.views.TurnView import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView @@ -82,6 +83,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("", panel.dump()) } + fun `test transcript content has symmetric side padding`() { + model.upsertMessage(msg("a1", "assistant")) + + panel.setSize(600, 400) + panel.doLayout() + val turn = panel.components.first { it is TurnView } + val left = turn.x + val right = panel.width - turn.x - turn.width + + assertEquals(right, left) + } + // ------ TurnAdded ------ fun `test user message creates turn and is findable by message id`() { 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 index 4aaa851181c..428f682fb62 100644 --- 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 @@ -42,10 +42,12 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { organizations: List = emptyList(), balance: ProfileBalanceDto? = null, currentOrgId: String? = null, + hasPersonalAccount: Boolean = true, ) = ProfileDto( email = email, name = name, organizations = organizations, + hasPersonalAccount = hasPersonalAccount, balance = balance, currentOrgId = currentOrgId, ) @@ -106,6 +108,22 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { } } + fun `test profile without personal account hides personal choice`() { + val acme = org("org_1", "Acme", "MEMBER") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = "org_1", + hasPersonalAccount = false, + ) + show(snap(prof)) + edt { + assertEquals("Acme", panel.accountTitle()) + assertEquals(1, panel.choiceCount()) + assertEquals(0, panel.selectedIndex()) + } + } + // --- test 4: programmatic update does not call select callback --- fun `test programmatic update does not call select callback`() { 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 4a3675637de..cfed8dcef3b 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 @@ -7,7 +7,9 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextArea import java.awt.Color import java.awt.Component import java.awt.Container @@ -104,6 +106,22 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertTrue(view.bodyText().contains("Manual verification, Unit tests")) } + fun `test answer rows keep standard gap after question text`() { + val view = QuestionResultView(completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + )) + + view.toggle() + val root = view.node(0) + val body = root.node(1) + val row = body.node(0) + val text = row.components[0] as JBTextArea + val ins = text.border.getBorderInsets(text) + + assertEquals(UiStyle.Gap.xs(), ins.bottom) + } + fun `test label shows count of non-empty answers`() { val tool = completedTool( input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""), 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 9305d3114a8..e2591ece67b 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 @@ -17,6 +17,7 @@ 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 com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Component import java.awt.Container @@ -24,6 +25,7 @@ import kotlin.math.abs import javax.swing.AbstractButton import javax.swing.JButton import javax.swing.JComponent +import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -269,6 +271,30 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals("hint should use hintFont", style.hintFont, hint.font) } + fun `test custom answer editor uses prompt text styling`() { + view.show(customSingleQuestion("q_custom_style")) + + findAll(view).first { it.actionCommand == "" }.doClick() + val field = findAll(view).first() + view.addNotify() + try { + layout(view) + UIUtil.dispatchAllInvocationEvents() + val editor = field.getEditor(true) ?: error("missing editor") + val style = SessionEditorStyle.current() + + assertEquals(style.transcriptFont, field.font) + assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName) + assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertTrue(editor.settings.isUseSoftWraps) + assertFalse(editor.settings.isPaintSoftWraps) + } finally { + view.hideView() + view.removeNotify() + } + } + // ------ multi-question navigation ------ fun `test multi question shows one question at a time and navigates`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt index d9f1ac10f19..763764f0045 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt @@ -33,7 +33,12 @@ class ReasoningViewStressTest : BasePlatformTestCase() { assertEquals(count, panel(view).componentCount) view.update(reasoning("r1", done = true, text = view.markdown() + "```")) - assertTrue(view.bodyVisible()) + assertFalse(view.bodyVisible()) + assertFalse(view.bodyCreated()) + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + Disposer.dispose(view) drainEdt() 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 e718bdcc8b6..63972f05ab0 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 @@ -4,6 +4,8 @@ import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI @@ -11,6 +13,7 @@ import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container import javax.swing.Icon +import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -59,16 +62,37 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) } - fun `test live reasoning stays expanded when marked done`() { + fun `test live reasoning auto-collapses and releases body when marked done`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) assertTrue(view.isExpanded()) + assertTrue(view.bodyCreated()) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertFalse(view.bodyCreated()) + + view.toggle() + assertTrue(view.isExpanded()) assertTrue(view.bodyVisible()) assertTrue(view.bodyCreated()) + assertEquals("one\ntwo\nthree\nfour", view.markdown()) + } + + fun `test auto-collapse releases streaming reasoning editors`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(20) { i -> + val view = ReasoningView(reasoning("p$i", done = false, text = "```kotlin\nval x = $i\n```")) + popupEditors(view.md.component).forEach { it.getEditor(true) } + view.update(reasoning("p$i", done = true, text = "```kotlin\nval x = $i\n```")) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) } fun `test manually expanded finished reasoning stays open on update`() { @@ -91,6 +115,39 @@ class ReasoningViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) } + fun `test manual collapse during stream stays collapsed when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) + + view.toggle() + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) + + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("one\ntwo\nthree", view.markdown()) + } + + fun `test manual expand during stream stays open when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) + + view.toggle() + view.toggle() + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("one\ntwo\nthree", view.markdown()) + } + + fun `test unpinned appended reasoning auto-collapses when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one")) + + view.appendDelta("\ntwo") + view.update(reasoning("p1", done = true, text = "one\ntwo")) + + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + } + fun `test collapsed reasoning stays collapsed on update`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) @@ -200,15 +257,60 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.headerFont().size < style.editorSize) } - fun `test expanded reasoning body is capped to five rows`() { + fun `test expanded reasoning body is capped to configured rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) val taller = ReasoningView(reasoning("p2", done = false, text = (1..200).joinToString("\n") { "line $it" })) - assertEquals(5, view.bodyMaxRows()) + assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) assertEquals(view.preferredSize.height, taller.preferredSize.height) } + fun `test reasoning header popup is available only when collapsed with content`() { + val expanded = ReasoningView(reasoning("p1", done = false, text = "one")) + val blank = ReasoningView(reasoning("p2", done = true, text = "")) + val collapsed = ReasoningView(reasoning("p3", done = true, text = "one\ntwo")) + + assertNull(expanded.headerPopup()) + assertNull(blank.headerPopup()) + assertNotNull(collapsed.headerPopup()) + + collapsed.toggle() + + assertNull(collapsed.headerPopup()) + } + + fun `test reasoning header popup body is capped to popup size`() { + val text = (1..400).joinToString(" ") { "reasoning" } + val view = ReasoningView(reasoning("p1", done = true, text = text)) + val body = view.headerPopup()!!.build() + + try { + val scroll = popupScrollPanes(body.component).first() + val panel = scroll.viewport.view as JPanel + + assertEquals(1, panel.components.filterIsInstance().size) + assertTrue(body.component.preferredSize.width in 1..JBUI.scale(350)) + assertEquals(JBUI.scale(450), body.component.preferredSize.height) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test reasoning header popup editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + val view = ReasoningView(reasoning("p1", done = true, text = "```kotlin\nprintln(1)\n```")) + + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + fun `test appended reasoning scrolls nested body to bottom`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) view.setSize(300, 80) @@ -302,6 +404,26 @@ class ReasoningViewTest : BasePlatformTestCase() { return found } + private fun popupEditors(root: JComponent): List { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is com.intellij.ui.EditorTextField) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found + } + + private fun popupScrollPanes(root: JComponent): List { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is JBScrollPane) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found + } + private fun collect(component: Component, found: MutableList) { if (component is JLabel) component.icon?.let(found::add) if (component is Container) component.components.forEach { collect(it, found) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index b7817c62ab6..f9f05fd33d3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -328,7 +328,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(2, panes.size) labels.forEach { val label = it.border?.getBorderInsets(it) - assertEquals(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING), label?.left ?: 0) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), label?.left ?: 0) assertEquals(0, label?.right ?: 0) } panes.forEach { @@ -347,8 +347,7 @@ class ShellToolViewTest : BasePlatformTestCase() { val output = (1..30).joinToString("\n") { "line $it" } val view = track(ShellToolView(tool().also { it.output = output })) view.toggle() - val root = view.mdComponent()!! - val pane = root.components.filterIsInstance().single() + val pane = view.mdComponent()!!.components.filterIsInstance().single() val editor = view.codeEditors().single() val nested = editor.getEditor(true)!!.scrollPane val line = editor.getEditor(true)!!.lineHeight @@ -408,7 +407,7 @@ class ShellToolViewTest : BasePlatformTestCase() { val editor = field.getEditor(true)!! val lines = field.text.lines().size assertEquals( - SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, + SessionUiStyle.View.Code.topPadding(), pad.top, ) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) @@ -417,8 +416,9 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(field.preferredSize.height - border.top >= editor.lineHeight * lines) assertTrue(field.minimumSize.height - border.top >= editor.lineHeight * lines) assertTrue(pane.preferredSize.height >= field.preferredSize.height + pad.top + pad.bottom) - assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + assertTrue(body.component.preferredSize.width in 1..JBUI.scale(350)) assertTrue(body.component.preferredSize.height > 0) + assertTrue(body.component.preferredSize.height <= JBUI.scale(450)) } finally { Disposer.dispose(body.disposable) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index b4af0eba89f..28be9b3e762 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -7,9 +7,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color import javax.swing.JPanel @@ -99,6 +101,23 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals(UiStyle.Gap.pad(), ins.right) } + fun `test todo rows use session view gap`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("First", "pending", "medium"), + TodoDto("Second", "pending", "medium"), + ) + }) + val body = view.components.filterIsInstance().single() + + body.setSize(300, body.preferredSize.height) + body.doLayout() + + val rows = body.components.filterIsInstance().filter { it.isVisible } + val gap = rows[1].y - (rows[0].y + rows[0].height) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), gap) + } + fun `test compact view renders hidden labels and visible rows`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf( 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 fc6cb674cae..ed6e3e928fa 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 @@ -31,6 +31,14 @@ class FakeAppRpcApi : KiloAppRpcApi { val state = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)) var health = HealthDto(healthy = true, version = "1.0.0") + var cliVersion = "1.0.0" + var cliPlatform = "darwin-arm64" + var cliInfoGate: CompletableDeferred? = null + var cliInfoError: Exception? = null + var cliVersionCalls = 0 + private set + var cliPlatformCalls = 0 + private set var models = ModelStateDto() val selections = mutableListOf() val cleared = mutableListOf() @@ -67,6 +75,21 @@ class FakeAppRpcApi : KiloAppRpcApi { return health } + override suspend fun cliVersion(): String { + assertNotEdt("cliVersion") + cliVersionCalls += 1 + cliInfoGate?.await() + cliInfoError?.let { throw it } + return cliVersion + } + + override suspend fun cliPlatform(): String { + assertNotEdt("cliPlatform") + cliPlatformCalls += 1 + cliInfoError?.let { throw it } + return cliPlatform + } + override suspend fun retry() { assertNotEdt("retry") retries += 1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt index b3702301c9c..384f53b9c21 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt @@ -43,6 +43,6 @@ class UiStyleTest : BasePlatformTestCase() { assertTrue(JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING) > 0) assertTrue(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING) > 0) assertTrue(SessionUiStyle.View.Tool.BODY_LINES > 0) - assertTrue(SessionUiStyle.View.Reasoning.BODY_LINES > 0) + assertEquals(5, SessionUiStyle.View.Reasoning.BODY_LINES) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index b80b7489614..e78d86f8782 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -122,25 +122,31 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.preferredSize.height >= line * 3) } - fun `test fenced code block has symmetric content padding with horizontal scrollbar`() { + fun `test fenced code block balances content padding with horizontal scrollbar`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() val pad = pane.viewportBorder.getBorderInsets(pane) - assertEquals(SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, pad.top) + assertEquals(SessionUiStyle.View.Code.topPadding(), pad.top) + assertEquals(SessionUiStyle.View.Layout.HORIZONTAL_PADDING, pad.left) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) - assertEquals(pad.top, pad.bottom) + assertEquals(SessionUiStyle.View.Layout.HORIZONTAL_PADDING, pad.right) + assertTrue(pad.top > pad.bottom) + assertTrue(pad.top < pad.bottom + SessionUiStyle.View.Code.SCROLLBAR_HEIGHT) assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) } - fun `test short code block keeps symmetric content padding`() { + fun `test short code block keeps balanced content padding`() { view.set("```text\n[ALICE, ANNA]\n```") val pane = scrolls().single() val pad = pane.viewportBorder.getBorderInsets(pane) - assertEquals(SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, pad.top) + assertEquals(SessionUiStyle.View.Code.topPadding(), pad.top) + assertEquals(SessionUiStyle.View.Layout.HORIZONTAL_PADDING, pad.left) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) - assertEquals(pad.top, pad.bottom) + assertEquals(SessionUiStyle.View.Layout.HORIZONTAL_PADDING, pad.right) + assertTrue(pad.top > pad.bottom) + assertTrue(pad.top < pad.bottom + SessionUiStyle.View.Code.SCROLLBAR_HEIGHT) } fun `test fenced code block height is not capped`() { diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 437f5511486..a2c507d77f5 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.1 +kilo.jetbrains.version=7.0.2 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 2cfb8872e11..15b48416fe5 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -11,6 +11,7 @@ detekt = "1.23.8" commonmark = "0.28.0" zxing = "3.5.3" changelog = "2.5.0" +commons-compress = "1.28.0" [libraries] commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } @@ -23,6 +24,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version. 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" } +commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" } [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/packages/kilo-jetbrains/script/build-version.sh b/packages/kilo-jetbrains/script/build-version.sh index ff344202405..4cd0c7663d4 100755 --- a/packages/kilo-jetbrains/script/build-version.sh +++ b/packages/kilo-jetbrains/script/build-version.sh @@ -6,7 +6,7 @@ usage() { Usage: $0 [options] Builds the JetBrains plugin for a version without creating or validating a git tag. -By default this runs a clean build, prepares production CLI binaries, signs the ZIP, and verifies it. +By default this runs a clean build, signs the ZIP, and verifies it. Version: x.y.z or x.y.z-rc.n, with an optional leading v. @@ -75,7 +75,6 @@ fi script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" plugin="$(cd "${script}/.." && pwd)" -cli="$(cd "${plugin}/../opencode" && pwd)" secrets="${HOME}/.secrets/jetbrains" chain="${secrets}/chain.crt" key="${secrets}/private.pem" @@ -87,11 +86,6 @@ if [[ ! -d "$plugin" ]]; then exit 1 fi -if [[ ! -f "${cli}/package.json" ]]; then - echo "Expected CLI package at $cli" >&2 - exit 1 -fi - if [[ "$sign" == "1" ]]; then for file in "$chain" "$key" "$pass"; do if [[ ! -s "$file" ]]; then @@ -117,8 +111,6 @@ if [[ "$clean" == "1" ]]; then ./gradlew clean fi -rm -rf "${cli}/dist" -KILO_VERSION="$version" KILO_CHANNEL=rc bun "${plugin}/script/build.ts" --production --prepare-cli ./gradlew buildPlugin -Pproduction=true -Pkilo.version="$version" -Pkilo.channel=eap if [[ "$sign" == "1" ]]; then diff --git a/packages/kilo-jetbrains/script/build.ts b/packages/kilo-jetbrains/script/build.ts index 666040cc6cb..27cc3cb49a3 100644 --- a/packages/kilo-jetbrains/script/build.ts +++ b/packages/kilo-jetbrains/script/build.ts @@ -4,124 +4,24 @@ * Build the Kilo JetBrains plugin. * * Usage: - * bun script/build.ts # Local build — only current platform binary required - * bun script/build.ts --production # Production build — all 6 platform binaries required - * bun script/build.ts --prepare-cli # Only prepare local CLI resources for Gradle + * bun script/build.ts # Local plugin build + * bun script/build.ts --production # Production plugin build * - * Steps: - * 1. Builds CLI binaries (or uses prebuilt ones from dist/). - * Local: builds only current platform (--single). - * Production: builds all platforms. - * 2. Copies them and the Kilo sandbox mutation worker into backend/build/generated/cli/cli/{os}/ - * so they end up inside the backend jar at /cli/{os}/. - * 3. Invokes Gradle to build the plugin. + * The JetBrains plugin no longer bundles CLI binaries. Runtime downloads the + * pinned release from GitHub using packages/kilo-jetbrains/package.json version. */ import { $ } from "bun" -import { join, relative } from "node:path" -import { existsSync, mkdirSync, chmodSync, cpSync, rmSync } from "node:fs" +import { join } from "node:path" const production = process.argv.includes("--production") -const cliOnly = process.argv.includes("--prepare-cli") - const root = join(import.meta.dir, "..") -const packages = join(root, "..") -const opencodeDir = join(packages, "opencode") -const distDir = join(opencodeDir, "dist") -const cliDir = join(root, "backend", "build", "generated", "cli", "cli") - -/** All desktop platforms. */ -const platforms = [ - { os: "darwin-arm64", exe: "kilo" }, - { os: "darwin-x64", exe: "kilo" }, - { os: "linux-arm64", exe: "kilo" }, - { os: "linux-x64", exe: "kilo" }, - { os: "windows-x64", exe: "kilo.exe" }, - { os: "windows-arm64", exe: "kilo.exe" }, -] as const - -function localPlatformTag(): string { - const os = process.platform === "win32" ? "windows" : process.platform - return `${os}-${process.arch}` -} +const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew" function log(msg: string) { console.log(`[jetbrains-build] ${msg}`) } -function distBinPath(os: string, exe: string): string { - return join(distDir, `@kilocode/cli-${os}`, "bin", exe) -} - -function hasArtifacts(os: string, exe: string): boolean { - return existsSync(distBinPath(os, exe)) && existsSync(distBinPath(os, "kilo-sandbox-mutation-worker.js")) -} - -function hasDist(): boolean { - if (production) { - return platforms.every((p) => hasArtifacts(p.os, p.exe)) - } - const tag = localPlatformTag() - const local = platforms.find((p) => p.os === tag) - return local ? hasArtifacts(local.os, local.exe) : false -} - -async function prepareCli() { - const mode = production ? "production" : "local" - log(`Mode: ${mode}`) - - if (!hasDist()) { - log("Building CLI binaries via opencode...") - if (!existsSync(join(opencodeDir, "package.json"))) { - throw new Error(`Expected opencode package at ${opencodeDir}`) - } - const args = production ? [] : ["--single"] - await $`bun run build ${args}`.cwd(opencodeDir) - } else { - log("Found prebuilt CLI binaries in opencode/dist/, skipping CLI build") - } - - if (existsSync(cliDir)) { - rmSync(cliDir, { recursive: true }) - } - - const missing: string[] = [] - let copied = 0 - for (const p of platforms) { - const src = distBinPath(p.os, p.exe) - const worker = distBinPath(p.os, "kilo-sandbox-mutation-worker.js") - if (!existsSync(src) || !existsSync(worker)) { - missing.push(p.os) - continue - } - - const dir = join(cliDir, p.os) - mkdirSync(dir, { recursive: true }) - const dest = join(dir, p.exe) - cpSync(src, dest) - cpSync(worker, join(dir, "kilo-sandbox-mutation-worker.js")) - chmodSync(dest, 0o755) - copied++ - log(`Copied ${relative(root, src)} and Kilo sandbox mutation worker -> ${relative(root, dir)}`) - } - - if (copied === 0) { - throw new Error("No CLI binaries were copied — the build cannot proceed") - } - - if (production && missing.length > 0) { - throw new Error(`Production build requires all platform binaries. Missing: ${missing.join(", ")}`) - } - - if (missing.length > 0) { - log(`Skipped ${missing.length} platforms (not needed for local build): ${missing.join(", ")}`) - } - - log(`Copied ${copied}/${platforms.length} platform binaries`) -} - -const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew" - async function buildPlugin() { log("Building JetBrains plugin via Gradle...") const args = production ? ["-Pproduction=true"] : [] @@ -130,10 +30,7 @@ async function buildPlugin() { } try { - await prepareCli() - if (!cliOnly) { - await buildPlugin() - } + await buildPlugin() } catch (err) { console.error(`[jetbrains-build] ERROR: ${err instanceof Error ? err.message : String(err)}`) process.exit(1) 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 fa1bb85079b..e05b4c3bdf1 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 @@ -39,13 +39,19 @@ interface KiloAppRpcApi : RemoteApi { /** One-shot health check against /global/health. */ suspend fun health(): HealthDto + /** Pinned Core version bundled in backend resources. */ + suspend fun cliVersion(): String + + /** Core platform downloaded by the backend process. */ + suspend fun cliPlatform(): String + /** Retry app connection or loading after a failure. */ suspend fun retry() - /** Kill the CLI process and restart it. */ + /** Kill the Core process and restart it. */ suspend fun restart() - /** Kill the CLI process, re-extract the binary, and restart. */ + /** Kill the Core process, re-download the binary, and restart. */ suspend fun reinstall() /** Load persisted CLI model state such as favorites. */ 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 cbc72c6db79..4667d8f7b19 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 @@ -6,6 +6,7 @@ import kotlinx.serialization.SerialName @Serializable enum class KiloAppStatusDto { DISCONNECTED, + DOWNLOADING, CONNECTING, LOADING, MIGRATION_REQUIRED, @@ -155,6 +156,7 @@ data class ProfileDto( val email: String, val name: String? = null, val organizations: List = emptyList(), + val hasPersonalAccount: Boolean = true, val balance: ProfileBalanceDto? = null, val kiloPass: ProfileKiloPassDto? = null, val currentOrgId: String? = null, @@ -173,6 +175,9 @@ data class KiloAppStateDto( val error: String? = null, val errors: List = emptyList(), val progress: LoadProgressDto? = null, + val downloadPercent: Int? = null, + val downloadVersion: String? = null, + val downloadPlatform: String? = null, val warnings: List = emptyList(), val config: ConfigDto? = null, val profile: ProfileDto? = null, diff --git a/packages/kilo-memory/src/marker-meta.ts b/packages/kilo-memory/src/marker-meta.ts index 727b147da4f..b2db71714f8 100644 --- a/packages/kilo-memory/src/marker-meta.ts +++ b/packages/kilo-memory/src/marker-meta.ts @@ -20,6 +20,7 @@ export namespace MemoryMarkerMeta { type: Type tokens: number count: number + files: string[] } export function metadata(marker: Info) { @@ -30,7 +31,6 @@ export namespace MemoryMarkerMeta { tokens: marker.tokens, count: marker.count, files: marker.files, - sources: marker.files, }, } } @@ -89,13 +89,14 @@ export namespace MemoryMarkerMeta { const value = meta as { type?: unknown; tokens?: unknown; count?: unknown; files?: unknown; sources?: unknown } const type = value.type === "startup" ? "startup" : "recall" const tokens = typeof value.tokens === "number" ? value.tokens : 0 + // `sources` fallback covers parts persisted before the key was dropped from metadata(). const files = Array.isArray(value.files) ? value.files.filter((item) => typeof item === "string") : Array.isArray(value.sources) ? value.sources.filter((item) => typeof item === "string") : [] const count = typeof value.count === "number" ? value.count : files.length - return { type, tokens, count } + return { type, tokens, count, files } } return undefined } diff --git a/packages/kilo-memory/test/decisions.test.ts b/packages/kilo-memory/test/decisions.test.ts index f75731af0d2..c5eb563ebd5 100644 --- a/packages/kilo-memory/test/decisions.test.ts +++ b/packages/kilo-memory/test/decisions.test.ts @@ -162,6 +162,20 @@ describe("memory marker metadata", () => { type: "recall", tokens: 4, count: 2, + files: ["project.md", "environment.md"], + }) + }) + + test("decodes legacy metadata that only carries sources", () => { + expect( + MemoryMarkerMeta.fromParts([ + { type: "text", metadata: { kiloMemory: { type: "startup", tokens: 3, sources: ["project.md"] } } }, + ]), + ).toEqual({ + type: "startup", + tokens: 3, + count: 1, + files: ["project.md"], }) }) diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 773d17d1f5c..87172ed841d 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -42,6 +42,10 @@ const icons: Record = { viewBox: "0 0 16 16", path: ``, }, + reload: { + viewBox: "0 0 24 24", + path: ``, + }, } type Name = keyof typeof icons diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 5a3fd732da0..d55bc2c4ce9 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -412,6 +412,35 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { margin-bottom: 0px; } +[data-slot="tool-read-images"] { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 8px; + max-width: min(100%, 64ch); +} + +[data-slot="tool-read-image"] { + max-width: 200px; + max-height: 200px; + border-radius: var(--radius-sm, 6px); + overflow: hidden; + border: 1px solid var(--border-weak-base); + cursor: pointer; + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--border-strong-base); + } +} + +[data-slot="tool-read-image-img"] { + display: block; + max-width: 100%; + max-height: 200px; + object-fit: contain; +} + [data-component="todos"] { [data-slot="message-part-todo-hidden"] { padding: 2px 0 2px 28px; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 7fca6106088..a5ab17ed51d 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -995,6 +995,7 @@ export interface ToolProps { callID?: string output?: string status?: string + attachments?: FilePart[] hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean @@ -1243,6 +1244,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { // @ts-expect-error output={part.state.output} status={part.state.status} + // @ts-expect-error + attachments={part.state.attachments} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} animate @@ -1746,6 +1749,7 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const dialog = useDialog() const args: string[] = [] if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) @@ -1755,6 +1759,10 @@ ToolRegistry.register({ return value.filter((p): p is string => typeof p === "string") }) const pending = createMemo(() => busy(props.status)) + const images = createMemo(() => + (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url), + ) + const preview = (url: string, alt?: string) => dialog.show(() => ) return ( <> )} + 0}> +
+ + {(file) => ( +
preview(file.url, file.filename)}> + {file.filename +
+ )} +
+
+
) }, diff --git a/packages/kilo-ui/src/components/tabs.css b/packages/kilo-ui/src/components/tabs.css index a581a0edf0d..01b488316f6 100644 --- a/packages/kilo-ui/src/components/tabs.css +++ b/packages/kilo-ui/src/components/tabs.css @@ -45,11 +45,11 @@ cursor: pointer; } - &[data-variant="settings"] { + &[data-orientation="vertical"][data-variant="settings"] { container-type: inline-size; [data-slot="tabs-list"] { - width: 180px; + width: auto; min-width: 180px; } diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index b36ae0fd196..d19df68b45e 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -73,7 +73,10 @@ export default [ }, { files: ["webview-ui/src/context/session.tsx"], - rules: { complexity: ["error", 31] }, + // Raised from the default 3000 as this session context grew past the cap + // after upstream merges; kept as a targeted override rather than loosening + // the global limit. + rules: { complexity: ["error", 31], "max-lines": ["error", 3100] }, }, { files: ["src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index f91fb383f19..4cc942b5a37 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -195,6 +195,16 @@ "title": "Migrate Settings from Legacy Version", "category": "Kilo Code" }, + { + "command": "kilo-code.new.showMemory", + "title": "Show Project Memory", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.toggleMemory", + "title": "Toggle Project Memory", + "category": "Kilo Code" + }, { "command": "kilo-code.new.autocomplete.generateSuggestions", "title": "Generate Suggested Edits", @@ -422,6 +432,11 @@ "command": "kilo-code.new.takeHeapSnapshot", "title": "Take Heap Snapshot", "category": "Kilo Code" + }, + { + "command": "kilo-code.new.reload", + "title": "Reload Config and Skills", + "category": "Kilo Code" } ], "submenus": [ @@ -865,6 +880,57 @@ "Italiano" ] }, + "kilo-code.new.languageCommitMessage": { + "type": "string", + "default": "sync", + "description": "Language used for AI-generated commit messages. Choose 'sync' to follow the Kilo Code UI language, or select a specific language.", + "enum": [ + "sync", + "en", + "zh", + "zht", + "ko", + "de", + "es", + "fr", + "da", + "ja", + "pl", + "ru", + "ar", + "no", + "br", + "th", + "bs", + "tr", + "nl", + "uk", + "it" + ], + "enumDescriptions": [ + "跟随界面语言 (Sync with UI language)", + "English", + "简体中文", + "繁體中文", + "한국어", + "Deutsch", + "Español", + "Français", + "Dansk", + "日本語", + "Polski", + "Русский", + "العربية", + "Norsk", + "Português (Brasil)", + "ภาษาไทย", + "Bosanski", + "Türkçe", + "Nederlands", + "Українська", + "Italiano" + ] + }, "kilo-code.new.model.providerID": { "type": "string", "default": "kilo", @@ -1142,6 +1208,7 @@ "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", + "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2d85c39ac24..21939cc65fa 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -138,6 +138,7 @@ import { import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments" +import { KiloProviderMemory } from "./kilo-provider/memory" import { buildActionContext, @@ -386,6 +387,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly confirmations = new MessageConfirmation() private readonly costs = new MaxCostNudge() private readonly activeAlerts = new Map() // sid -> limit currently shown in UI + private readonly memory = new KiloProviderMemory({ + client: () => this.client ?? undefined, + session: () => this.currentSession ?? undefined, + // Honor disabled project scope (null in a multi-root panel): no workspace fallback, + // so memory operations never silently target an arbitrary folder. + dir: (sessionID) => this.getProjectDirectory(sessionID), + post: (message) => this.postMessage(message), + }) private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null /** Cached migration data so migration doesn't re-read from disk/SecretStorage. */ // legacy-migration @@ -931,6 +940,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } if (await this.handleModelSelectorExpandedMessage(message)) return this.visibleTaskStreams.handle(message) + if (await this.handleMemoryMessage(message)) return switch (message.type) { case "webviewReady": console.log("[Kilo New] KiloProvider: ✅ webviewReady received") @@ -1075,6 +1085,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] KiloProvider: ❌ Retry connection failed:", e), ) break + case "reload": + this.handleReload().catch((e) => console.error("[Kilo New] KiloProvider: Reload failed:", e)) + break case "openSubAgentViewer": vscode.commands.executeCommand("kilo-code.new.openSubAgentViewer", message.sessionID, message.title) break @@ -1512,6 +1525,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Remote status events are global and should always pass through if (event.type === "kilo-sessions.remote-status-changed") return true + if (event.type === "memory.status" || event.type === "memory.updated" || event.type === "memory.error") + return true const sessionId = this.resolveEventSessionId(event) // message.part.* events are always session-scoped; drop if session unknown. @@ -1656,6 +1671,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendConfig(), this.fetchAndSendIndexingStatus(), this.fetchAndSendNotifications(), + this.memory.fetch(), this.seedSessionStatusMap(), ]) this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory()) @@ -2387,6 +2403,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async handleMemoryMessage(message: Record): Promise { + return this.memory.handle(message) + } + /** * Fetch backend config and send to webview. */ @@ -2418,7 +2438,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2514,7 +2534,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(config), } this.postMessage({ @@ -2522,7 +2542,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(config), }) } catch (error) { @@ -2904,7 +2924,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config: merged, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(merged), } this.postMessage({ @@ -2912,7 +2932,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config: merged, globalConfig: global, projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: configFeatures(merged), }) this.requirements.clear() @@ -2934,7 +2954,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper type: "configUpdated", config: optimistic, globalConfig: this.cachedGlobalConfig ?? undefined, - settings: { maxCost: this.maxCostSetting() }, + settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, features: features ?? configFeatures(optimistic as Config), }) this.requirements.clear() @@ -3075,6 +3095,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return this.setMaxCost(vscode.workspace.getConfiguration("kilo-code.new").get("maxCost", 0)) } + private commitMessageLanguageSetting(): string { + return vscode.workspace.getConfiguration("kilo-code.new").get("languageCommitMessage", "sync") + } + private setMaxCost(value: unknown): number { maxCost = MaxCostNudge.normalizeLimit(typeof value === "number" ? value : Number(value)) ?? 0 this.costs.setLimit(maxCost) @@ -3608,6 +3632,41 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ]) } + /** Reload config, skills, agents, and commands from disk by rebooting the instance. */ + private async handleReload(): Promise { + if (!this.client) { + console.warn("[Kilo New] handleReload: no client connection") + return + } + const dir = this.getWorkspaceDirectory(this.currentSession?.id) + try { + await this.client.instance.reload({ directory: dir }, { throwOnError: true }) + } catch (err) { + const status = + err && typeof err === "object" && "response" in err + ? (err as { response?: { status?: number } }).response?.status + : undefined + if (status === 409) { + vscode.window.showWarningMessage( + "Cannot reload while a session is running. Wait for it to finish or abort it first.", + ) + } else { + console.error("[Kilo New] handleReload: reload endpoint failed:", err) + vscode.window.showErrorMessage("Reload failed. See extension logs for details.") + } + return + } + this.clearCommandsCache() + if (!sameDirectory(dir, this.getWorkspaceDirectory())) { + await this.reloadAfterAuthChange() + } + } + + /** Public reload entry point for VS Code commands. */ + async reload(): Promise { + return this.handleReload() + } + private mapSyncEventToWebviewMessage(event: LegacySyncEvent) { switch (event.type) { case "message.updated": { @@ -3724,6 +3783,48 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + if (event.type === "memory.status" || event.type === "memory.updated" || event.type === "memory.error") { + const props = event.properties as { sessionID?: unknown; detail?: unknown; reason?: unknown } + const eventSessionID = typeof props.sessionID === "string" ? props.sessionID : undefined + const active = this.currentSession?.id + const local = + !directory || sameDirectory(directory, this.getProjectDirectory(active) ?? this.getWorkspaceDirectory(active)) + const trackedById = Boolean(eventSessionID && this.trackedSessionIds.has(eventSessionID)) + // Directory-scoped events (enable/disable/rebuild/configure/purge) carry no + // sessionID, so also match any tracked session sharing the event directory — + // e.g. a non-active Agent Manager tab on the same worktree. + const trackedByDir = directory + ? [...this.sessionDirectories.entries()] + .filter(([sid, dir]) => this.trackedSessionIds.has(sid) && sameDirectory(directory, dir)) + .map(([sid]) => sid) + : [] + const tracked = trackedById || trackedByDir.length > 0 + if (!local && !tracked) return + if (trackedById && eventSessionID && directory) this.trackDirectory(eventSessionID, directory) + const targets = new Set() + if (trackedById && eventSessionID) targets.add(eventSessionID) + for (const sid of trackedByDir) targets.add(sid) + if (local && active) targets.add(active) + if (targets.size === 0 && local) targets.add(undefined) + const detail = + props.detail && typeof props.detail === "object" + ? props.detail + : event.type === "memory.error" && typeof props.reason === "string" + ? { type: "error", message: props.reason, reason: props.reason } + : undefined + for (const sessionID of targets) { + if (detail) { + this.postMessage({ + type: "memoryEvent", + sessionID, + detail, + }) + } + void this.memory.fetch(sessionID, false) + } + return + } + // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. @@ -3969,6 +4070,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.flushPendingReviewComments() } + public async showMemory(sessionID?: string): Promise { + await this.memory.show(sessionID ?? this.currentSession?.id) + } + + public async toggleMemory(sessionID?: string): Promise { + try { + const operation = await this.memory.toggle(sessionID ?? this.currentSession?.id) + if (operation) { + void vscode.window.showInformationMessage(`Project memory ${operation === "enable" ? "enabled" : "disabled"}.`) + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to toggle memory:", error) + void vscode.window.showErrorMessage(getErrorMessage(error) || "Failed to toggle memory") + } + } + private flushPendingReviewComments(): void { if (!this.webview || !this.isWebviewReady || this.pendingReviewComments.length === 0) return @@ -4260,6 +4377,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibleTaskStreams.clear() this.streams.dispose() this.isWebviewReady = false + // Release any waitForReady() awaiters so their callers don't hang after disposal. + this.readyResolvers.splice(0).forEach((r) => r()) this.promptRecoveryQueued = false clearNetworkWaits(this.trackedSessionIds) this.trackedSessionIds.clear() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e906a76607c..837af5ae746 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1804,6 +1804,60 @@ export class AgentManagerProvider implements Disposable { return this.panel?.active === true } + private async waitForPanel(panel: PanelContext, promise: Promise): Promise { + const done = promise.then(() => true) + let sub: Disposable | undefined + const disposed = new Promise((resolve) => { + sub = panel.onDidDispose(() => { + sub?.dispose() + resolve(false) + }) + }) + void done.finally(() => sub?.dispose()) + const ok = await Promise.race([done, disposed]) + return ok && this.panel === panel + } + + private waitForPanelReady(panel: PanelContext): Promise { + return this.waitForPanel(panel, panel.waitForReady()) + } + + private waitForPanelActive(panel: PanelContext): Promise { + return this.waitForPanel(panel, panel.waitForActive()) + } + + public async showMemory(): Promise { + const panel = this.panel + const sid = this.activeSessionId + if (!panel || !sid) { + this.host.showError("No active Agent Manager session") + return + } + if (!(await this.waitForPanelReady(panel))) return + if (this.activeSessionId !== sid) return + try { + await panel.sessions.showMemory(sid) + } catch (error) { + this.host.showError(getErrorMessage(error) || "Failed to show memory") + } + } + + public async toggleMemory(): Promise { + const panel = this.panel + const sid = this.activeSessionId + if (!panel || !sid) { + this.host.showError("No active Agent Manager session") + return + } + if (!(await this.waitForPanelReady(panel))) return + if (this.activeSessionId !== sid) return + try { + await panel.sessions.toggleMemory(sid) + } catch (error) { + this.host.showError(getErrorMessage(error) || "Failed to toggle memory") + } + } + /** Expose worktree session→directory mappings for the auto-approve toggle. */ public getSessionDirectories(): ReadonlyMap { return this.panel?.sessions.getSessionDirectories() ?? new Map() @@ -1869,7 +1923,7 @@ export class AgentManagerProvider implements Disposable { this.openPanel() const panel = this.panel if (!panel) return - await panel.waitForReady() + if (!(await this.waitForPanelReady(panel))) return await this.waitForStateReady("createFromSidebar") await this.onCreateWorktree(baseBranch, branchName) } @@ -1878,8 +1932,8 @@ export class AgentManagerProvider implements Disposable { this.openPanel() const panel = this.panel if (!panel) return - await panel.waitForActive() - await panel.waitForReady() + if (!(await this.waitForPanelActive(panel))) return + if (!(await this.waitForPanelReady(panel))) return await this.waitForStateReady("openAdvancedWorktree") queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" })) } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 2d9b4f6bf4c..fc5be00112f 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -44,6 +44,8 @@ export interface SessionProvider { * The callback receives the new session and its directory so the Agent Manager * can route it to the correct worktree instead of LOCAL. */ onFollowupAdopted(cb: (session: Session, directory: string) => void): void + showMemory(sessionID?: string): Promise + toggleMemory(sessionID?: string): Promise dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 79d88218790..24d96408772 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -119,6 +119,8 @@ export class VscodeHost implements Host { registerSession: (s) => provider.registerSession(s), recoverPendingPrompts: () => provider.recoverPendingPrompts(), onFollowupAdopted: (cb) => provider.onFollowupAdopted(cb), + showMemory: (id) => provider.showMemory(id), + toggleMemory: (id) => provider.toggleMemory(id), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 27472ec9c51..3e3aebd125c 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -398,6 +398,26 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.openIndexingSettings", () => { settingsEditorProvider.openPanel("settings", "indexing") }), + vscode.commands.registerCommand("kilo-code.new.showMemory", async () => { + if (agentManagerProvider.isActive()) { + await agentManagerProvider.showMemory() + return + } + const target = activeTabProvider() ?? provider + if (target === provider) await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await target.waitForReady() + await target.showMemory() + }), + vscode.commands.registerCommand("kilo-code.new.toggleMemory", async () => { + if (agentManagerProvider.isActive()) { + await agentManagerProvider.toggleMemory() + return + } + const target = activeTabProvider() ?? provider + if (target === provider) await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await target.waitForReady() + await target.toggleMemory() + }), // legacy-migration start vscode.commands.registerCommand("kilo-code.new.openMigrationWizard", () => { provider.postMessage({ type: "migrationState", needed: true, source: "legacy" }) @@ -530,6 +550,12 @@ export function activate(context: vscode.ExtensionContext) { registerHeapSnapshot(context, connectionService) + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.reload", () => { + provider.reload().catch((e) => console.error("[Kilo New] reload command failed:", e)) + }), + ) + // Register code actions (editor context menus, terminal context menus, keyboard shortcuts) registerCodeActions(context, provider, agentManagerProvider) registerTerminalActions(context, provider, agentManagerProvider) diff --git a/packages/kilo-vscode/src/kilo-provider/memory.ts b/packages/kilo-vscode/src/kilo-provider/memory.ts new file mode 100644 index 00000000000..fc0988bd985 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/memory.ts @@ -0,0 +1,483 @@ +import * as vscode from "vscode" +import * as path from "node:path" +import { + isMemoryOperation, + isMemoryPromptOperation, + type MemoryOperation, + type MemoryPromptOperation, +} from "@kilocode/kilo-memory/commands" +import { MemorySchema } from "@kilocode/kilo-memory/schema" +import type { KiloClient, Session } from "@kilocode/sdk/v2/client" +import { retry } from "../services/cli-backend/retry" +import { getErrorMessage } from "../kilo-provider-utils" + +type MemorySourceFile = MemorySchema.Source +type MemoryApi = KiloClient["memory"] +const CACHE_LIMIT = 8 +const NO_PROJECT = "No active project for memory. Open a file in the target folder to manage its memory." + +export type KiloProviderMemoryMessage = { + operation: MemoryOperation + sessionID?: string + mode?: "status" | "on" | "off" + confirm?: boolean + text?: string + query?: string + key?: string + file?: MemorySourceFile + section?: string +} + +export type KiloProviderMemoryInput = { + client(): KiloClient | undefined + session(): Session | undefined + /** Project directory for memory operations, or undefined when project scope is disabled. */ + dir(sessionID?: string): string | undefined + post(message: unknown): void +} + +function file(value: unknown): MemorySourceFile | undefined { + return MemorySchema.source(value) +} + +function operation(value: unknown): MemoryOperation | undefined { + return isMemoryOperation(value) ? value : undefined +} + +function mode(value: unknown) { + if (value === "status" || value === "on" || value === "off") return value + return undefined +} + +function memory(client: KiloClient | undefined): MemoryApi | undefined { + return (client as { memory?: MemoryApi } | undefined)?.memory +} + +function request(input: Record): { value: KiloProviderMemoryMessage } | { error: string } { + const op = operation(input.operation) + if (!op) return { error: "Unknown memory operation" } + const source = file(input.file) + if (input.file !== undefined && !source) return { error: "Invalid memory source file" } + return { + value: { + operation: op, + sessionID: typeof input.sessionID === "string" ? input.sessionID : undefined, + mode: mode(input.mode), + confirm: input.confirm === true, + text: typeof input.text === "string" ? input.text : undefined, + query: typeof input.query === "string" ? input.query : undefined, + key: typeof input.key === "string" ? input.key : undefined, + file: source, + section: typeof input.section === "string" ? input.section : undefined, + }, + } +} + +export class KiloProviderMemory { + private readonly cached = new Map() + private tail = Promise.resolve() + + constructor(private readonly input: KiloProviderMemoryInput) {} + + private cache(dir: string, msg: unknown) { + this.cached.delete(dir) + this.cached.set(dir, msg) + while (this.cached.size > CACHE_LIMIT) { + const key = this.cached.keys().next().value + if (typeof key !== "string") return + this.cached.delete(key) + } + } + + private serial(fn: () => Promise) { + // this.tail is always reassigned below to a never-rejecting promise, so it + // never settles rejected — a single fulfillment handler is sufficient. + const next = this.tail.then(fn) + this.tail = next.then( + () => undefined, + () => undefined, + ) + return next + } + + async handle(message: Record): Promise { + if (message.type === "requestMemory") { + this.fetch( + typeof message.sessionID === "string" ? message.sessionID : undefined, + message.includeSources === true, + ).catch((err: unknown) => console.error("[Kilo New] fetchAndSendMemory failed:", err)) + return true + } + if (message.type === "memoryShow") { + await this.show(typeof message.sessionID === "string" ? message.sessionID : undefined) + return true + } + if (message.type === "memoryOperation") { + const parsed = request(message) + if ("error" in parsed) { + this.input.post({ + type: "memoryOperationResult", + operation: typeof message.operation === "string" ? message.operation : "unknown", + sessionID: typeof message.sessionID === "string" ? message.sessionID : undefined, + ok: false, + error: parsed.error, + }) + return true + } + await this.run(parsed.value) + return true + } + if (message.type === "memoryPrompt") { + const op = isMemoryPromptOperation(message.operation) ? message.operation : undefined + if (!op) return true + await this.prompt(op, typeof message.sessionID === "string" ? message.sessionID : undefined) + return true + } + return false + } + + fetch(sessionID?: string, includeSources = false): Promise { + return this.serial(() => this.load(sessionID, includeSources)) + } + + /** Resolves once the serialized operation queue has drained. */ + idle(): Promise { + return this.tail + } + + private async load(sessionID?: string, includeSources = false): Promise { + try { + const directory = this.input.dir(sessionID ?? this.input.session()?.id) + const client = this.input.client() + if (!client) { + const cached = directory ? this.cached.get(directory) : undefined + if (cached && typeof cached === "object" && !Array.isArray(cached)) this.input.post({ ...cached, sessionID }) + else this.input.post({ type: "memoryLoaded", sessionID, error: "Not connected to CLI backend" }) + return + } + + const api = memory(client) + if (!api) { + this.input.post({ type: "memoryLoaded", sessionID, error: "Memory unavailable in CLI backend" }) + return + } + + if (!directory) { + this.input.post({ type: "memoryLoaded", sessionID, error: NO_PROJECT }) + return + } + + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + const show = includeSources + ? (await retry(() => api.show({ directory }, { throwOnError: true }))).data + : undefined + const msg = { + type: "memoryLoaded", + sessionID, + status, + ...(show ? { show } : {}), + } + this.cache(directory, msg) + this.input.post(msg) + } catch (err) { + console.error("[Kilo New] KiloProvider: Failed to fetch memory:", err) + this.input.post({ + type: "memoryLoaded", + sessionID, + error: getErrorMessage(err) || "Failed to load memory", + }) + } + } + + async prompt(value: MemoryPromptOperation, sessionID?: string): Promise { + const title = value === "remember" ? "Remember in project memory" : "Forget project memory" + const placeHolder = value === "remember" ? "Project fact, command, or correction" : "Text to remove" + const text = await vscode.window.showInputBox({ title, placeHolder, ignoreFocusOut: true }) + if (!text?.trim()) { + // Clear the webview's pending state for this action when the input is dismissed. + this.input.post({ type: "memoryOperationResult", operation: value, sessionID, ok: true }) + return + } + await this.run({ + operation: value, + sessionID, + ...(value === "remember" ? { text: text.trim() } : { query: text.trim() }), + }) + } + + show(sessionID?: string): Promise { + return this.serial(() => this.doShow(sessionID)) + } + + private async doShow(sessionID?: string): Promise { + const client = this.input.client() + if (!client) { + this.input.post({ + type: "memoryLoaded", + sessionID, + error: "Not connected to CLI backend", + }) + return + } + + const api = memory(client) + + if (!api) { + this.input.post({ + type: "memoryLoaded", + sessionID, + error: "Memory unavailable in CLI backend", + }) + return + } + + try { + const directory = this.input.dir(sessionID ?? this.input.session()?.id) + if (!directory) { + this.input.post({ type: "memoryLoaded", sessionID, error: NO_PROJECT }) + return + } + const { data: show } = await retry(() => api.show({ directory }, { throwOnError: true })) + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + const current = sessionID ?? this.input.session()?.id + const startup = + current && status.state.stats.lastInjectedSessionID === current ? status.state.stats.lastInjectedTokens : 0 + const content = [ + "# Kilo Memory", + "", + `Root: ${show.root}`, + `Enabled: ${show.state.enabled ? "yes" : "no"}`, + `Auto-save: ${show.state.autoConsolidate ? "on" : "off"}`, + `Startup context: ${show.state.autoInject ? "on" : "off"}`, + `Stored index tokens: ${status.index.estimatedTokens}`, + `Startup context tokens for this session: ${startup}`, + `Last auto-save model usage: ${status.state.stats.lastConsolidationTokens} tokens`, + "", + "## project.md", + show.sources.project.trim(), + "", + "## environment.md", + show.sources.environment.trim(), + "", + "## corrections.md", + show.sources.corrections.trim(), + "", + "## index.kmem", + show.index.trim(), + "", + "## items", + show.items.trim(), + "", + "## changes", + show.changes.trim(), + "", + "## decisions.jsonl", + show.decisions.trim(), + "", + ].join("\n") + await vscode.workspace + .openTextDocument({ content, language: "markdown" }) + .then((doc) => vscode.window.showTextDocument(doc, { preview: true })) + const msg = { + type: "memoryLoaded", + sessionID, + status, + show, + } + this.cache(directory, msg) + this.input.post(msg) + } catch (err) { + console.error("[Kilo New] KiloProvider: Failed to show memory:", err) + this.input.post({ + type: "memoryLoaded", + sessionID, + error: getErrorMessage(err) || "Failed to show memory", + }) + } + } + + run(message: KiloProviderMemoryMessage): Promise { + return this.serial(() => this.execute(message)) + } + + /** + * Serialized status read + enable/disable, so two rapid toggles can't both + * read the same pre-toggle state and apply the same operation twice. + * Returns the applied operation, or undefined when it failed (the failure is + * already posted to the webview by execute()). + */ + toggle(sessionID?: string): Promise { + return this.serial(async () => { + const client = this.input.client() + if (!client) throw new Error("Not connected to CLI backend") + const api = memory(client) + if (!api) throw new Error("Memory unavailable in CLI backend") + const directory = this.input.dir(sessionID ?? this.input.session()?.id) + if (!directory) throw new Error(NO_PROJECT) + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + const operation = status.state.enabled ? "disable" : "enable" + return (await this.execute({ operation, sessionID })) ? operation : undefined + }) + } + + private async execute(message: KiloProviderMemoryMessage): Promise { + const client = this.input.client() + if (!client) { + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: "Not connected to CLI backend", + }) + return false + } + + const api = memory(client) + if (!api) { + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: "Memory unavailable in CLI backend", + }) + return false + } + + try { + const directory = this.input.dir(message.sessionID ?? this.input.session()?.id) + if (!directory) { + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: NO_PROJECT, + }) + return false + } + const data = await this.action(api, directory, message) + const refreshed = await Promise.all([ + retry(() => api.status({ directory }, { throwOnError: true })), + retry(() => api.show({ directory }, { throwOnError: true })), + ]).catch((err: unknown) => { + console.warn("[Kilo New] Memory changed but refresh failed:", err) + return undefined + }) + const status = refreshed?.[0].data + const show = refreshed?.[1].data + const result = { + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: true, + ...(status ? { status } : {}), + ...(show ? { show } : {}), + result: data, + } + this.input.post(result) + if (status && show) { + const loaded = { + type: "memoryLoaded", + sessionID: message.sessionID, + status, + show, + } + this.cache(directory, loaded) + this.input.post(loaded) + } else { + // Mutation succeeded but the refresh failed: drop the now-stale cached + // entry so a later offline read doesn't report pre-mutation state. + this.cached.delete(directory) + } + return true + } catch (err) { + console.error("[Kilo New] KiloProvider: Failed memory operation:", err) + this.input.post({ + type: "memoryOperationResult", + operation: message.operation, + sessionID: message.sessionID, + ok: false, + error: getErrorMessage(err) || "Memory operation failed", + }) + return false + } + } + + private async action(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const op = message.operation + if (op === "enable") return (await api.enable({ directory }, { throwOnError: true })).data + if (op === "status") return (await api.status({ directory }, { throwOnError: true })).data + if (op === "edit") return this.edit(api, directory) + if (op === "disable") return (await api.disable({ directory }, { throwOnError: true })).data + if (op === "rebuild") return (await api.rebuild({ directory }, { throwOnError: true })).data + if (op === "purge") return this.purge(api, directory, message) + if (op === "auto") return this.auto(api, directory, message) + if (op === "remember") return this.remember(api, directory, message) + if (op === "correct") return this.correct(api, directory, message) + return this.forget(api, directory, message) + } + + private async remember(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const text = message.text?.trim() + if (!text) throw new Error("Memory text is required") + return ( + await api.remember( + { + directory, + text, + key: message.key, + file: message.file, + section: message.section, + sessionID: message.sessionID, + }, + { throwOnError: true }, + ) + ).data + } + + private async correct(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const text = message.text?.trim() + if (!text) throw new Error("Correction text is required") + return ( + await api.correct( + { + directory, + text, + key: message.key, + sessionID: message.sessionID, + }, + { throwOnError: true }, + ) + ).data + } + + private async forget(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + const query = message.query?.trim() + if (!query) throw new Error("Forget query is required") + return (await api.forget({ directory, query, sessionID: message.sessionID }, { throwOnError: true })).data + } + + private async edit(api: MemoryApi, directory: string) { + const { data: status } = await retry(() => api.status({ directory }, { throwOnError: true })) + if (!status.state.enabled) throw new Error("Memory is disabled. Run /memory on first.") + const uri = vscode.Uri.file(path.join(status.root, "project.md")) + const doc = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(doc, { preview: false }) + return status + } + + private async purge(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + if (message.confirm !== true) throw new Error("Memory purge requires confirmation") + return (await api.purge({ directory, confirm: true }, { throwOnError: true })).data + } + + private async auto(api: MemoryApi, directory: string, message: KiloProviderMemoryMessage) { + if (message.mode === "status") return (await retry(() => api.status({ directory }, { throwOnError: true }))).data + if (message.mode === "on" || message.mode === "off") { + return (await api.configure({ directory, autoConsolidate: message.mode === "on" }, { throwOnError: true })).data + } + throw new Error("Auto-save mode is required") + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts index a6c35399519..e22b07b40d0 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest" import { languageForFilepath, + languageForId, LANGUAGES, Typescript, JavaScript, @@ -31,6 +32,24 @@ import { } from "./AutocompleteLanguageInfo" describe("AutocompleteLanguageInfo", () => { + describe("languageForId", () => { + it("resolves VS Code language identifiers", () => { + expect(languageForId("typescript")).toBe(Typescript) + expect(languageForId("typescriptreact")).toBe(Typescript) + expect(languageForId("javascript")).toBe(JavaScript) + expect(languageForId("javascriptreact")).toBe(JavaScript) + expect(languageForId("jsonc")).toBe(Json) + expect(languageForId("python")).toBe(Python) + expect(languageForId("r")).toBe(R) + expect(languageForId("julia")).toBe(Julia) + expect(languageForId("luau")).toBe(Lua) + }) + + it("rejects unknown language identifiers", () => { + expect(languageForId("custom-language")).toBeUndefined() + }) + }) + describe("languageForFilepath", () => { describe("TypeScript/JavaScript files", () => { it("should return TypeScript for .ts files", () => { diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts index aee32df9230..012e37828aa 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts @@ -367,6 +367,42 @@ export const LANGUAGES: { [extension: string]: AutocompleteLanguageInfo } = { luau: Lua, } +const IDS: Record = { + typescript: Typescript, + typescriptreact: Typescript, + javascript: JavaScript, + javascriptreact: JavaScript, + json: Json, + jsonc: Json, + python: Python, + java: Java, + cpp: Cpp, + c: C, + csharp: CSharp, + scala: Scala, + go: Go, + rust: Rust, + haskell: Haskell, + php: PHP, + ruby: Ruby, + swift: Swift, + kotlin: Kotlin, + clojure: Clojure, + julia: Julia, + fsharp: FSharp, + r: R, + dart: Dart, + solidity: Solidity, + yaml: YAML, + markdown: Markdown, + lua: Lua, + luau: Lua, +} + +export function languageForId(id: string): AutocompleteLanguageInfo | undefined { + return IDS[id] +} + export function languageForFilepath(fileUri: string): AutocompleteLanguageInfo { const extension = getUriFileExtension(fileUri) return LANGUAGES[extension] || Typescript diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts index 2dca033b18e..15d3180d277 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts @@ -2,9 +2,10 @@ // https://github.com/continuedev/continue/blob/d0a3c0b626b5bebc3bef4742eec05a0242be0bab/extensions/vscode/src/autocomplete/completionProvider.ts#L226-L263 // Copyright 2023 Continue // Licensed under the Apache License, Version 2.0. -// Modified by Kilo Code for notebook paths, cursor positions, and cache scoping. +// Modified by Kilo Code for notebook paths, cursor positions, multilingual context, and cache scoping. import * as vscode from "vscode" +import { languageForId } from "./constants/AutocompleteLanguageInfo" export interface NotebookContext { contents: string @@ -53,7 +54,7 @@ export function notebookUri(uri: vscode.Uri): vscode.Uri | undefined { export function supportsNotebook(document: vscode.TextDocument): boolean { if (document.uri.scheme !== "vscode-notebook-cell") return true const resolved = resolveNotebook(document.uri) - return resolved?.cell.kind === vscode.NotebookCellKind.Code && document.languageId === "python" + return resolved?.cell.kind === vscode.NotebookCellKind.Code && !!languageForId(document.languageId) } export function autocompleteScope(document: vscode.TextDocument): string { @@ -64,7 +65,7 @@ export function autocompleteScope(document: vscode.TextDocument): string { const siblings = resolved.cells .filter((_, index) => index !== resolved.index) .map((cell) => [cell.document.uri.toString(), cell.kind, cell.document.languageId, cell.document.version]) - return JSON.stringify([id, resolved.notebook.uri.toString(), resolved.index, siblings]) + return JSON.stringify([id, document.languageId, resolved.notebook.uri.toString(), resolved.index, siblings]) } export function getNotebookContext( @@ -77,13 +78,24 @@ export function getNotebookContext( if (!resolved) return const cells = resolved.cells + const lang = languageForId(document.languageId) + if (!lang) return + + const json = document.languageId === "json" || document.languageId === "jsonc" + const marker = json ? undefined : lang.singleLineComment + const comment = (text: string, label: string) => + text + .split("\n") + .map((line, index) => (marker ? `${marker} ${index === 0 ? `[${label}] ` : ""}${line}` : "")) + .join("\n") + const contents = cells - .map((cell) => { + .map((cell, index) => { const text = cell.document.getText() - if (cell.kind === vscode.NotebookCellKind.Markup) { - return `"""${text}"""` - } - return text + if (index === resolved.index) return text + if (cell.kind === vscode.NotebookCellKind.Markup) return comment(text, "markdown") + if (!json && languageForId(cell.document.languageId) === lang) return text + return comment(text, cell.document.languageId) }) .join("\n\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts index 0e72eafcf79..5f6aa302da2 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix.ts @@ -1,6 +1,6 @@ import { IDE } from "../.." import { getRangeInString } from "../../util/ranges" -import { languageForFilepath } from "../constants/AutocompleteLanguageInfo" +import { languageForFilepath, languageForId } from "../constants/AutocompleteLanguageInfo" import { AutocompleteInput } from "../util/types" /** @@ -14,7 +14,7 @@ export async function constructInitialPrefixSuffix( prefix: string suffix: string }> { - const lang = languageForFilepath(input.filepath) + const lang = (input.languageId && languageForId(input.languageId)) || languageForFilepath(input.filepath) const fileContents = input.manuallyPassFileContents ?? (await ide.readFile(input.filepath)) const fileLines = fileContents.split("\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts index 000fc7ee905..ed2a8613e16 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/HelperVars.ts @@ -1,6 +1,6 @@ import { IDE, TabAutocompleteOptions } from "../.." import { countTokens, pruneLinesFromBottom, pruneLinesFromTop } from "../../llm/countTokens" -import { AutocompleteLanguageInfo, languageForFilepath } from "../constants/AutocompleteLanguageInfo" +import { AutocompleteLanguageInfo, languageForFilepath, languageForId } from "../constants/AutocompleteLanguageInfo" import { constructInitialPrefixSuffix } from "../templating/constructPrefixSuffix" import { AstPath, getAst, getTreePathAtCursor } from "./ast" @@ -35,7 +35,7 @@ export const HelperVars = { modelName: string, ide: IDE, ): Promise => { - const lang = languageForFilepath(input.filepath) + const lang = (input.languageId && languageForId(input.languageId)) || languageForFilepath(input.filepath) const workspaceUris = await ide.getWorkspaceDirs() const fileContents = input.manuallyPassFileContents ?? (await ide.readFile(input.filepath)) const fileLines = fileContents.split("\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts index 1b11559f8a0..a366bf48668 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/util/types.ts @@ -11,6 +11,7 @@ export interface AutocompleteInput { isUntitledFile: boolean completionId: string filepath: string + languageId?: string pos: Position recentlyVisitedRanges: AutocompleteCodeSnippet[] recentlyEditedRanges: RecentlyEditedRange[] diff --git a/packages/kilo-vscode/src/services/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/types.ts index b7a88433828..cd400d47865 100644 --- a/packages/kilo-vscode/src/services/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/types.ts @@ -32,6 +32,7 @@ export interface AutocompleteInput { isUntitledFile: boolean completionId: string filepath: string + languageId?: string pos: Position recentlyVisitedRanges: AutocompleteCodeSnippet[] recentlyEditedRanges: RecentlyEditedRange[] @@ -201,6 +202,7 @@ export function contextToAutocompleteInput(context: AutocompleteSuggestionContex isUntitledFile: context.document.isUntitled, completionId: crypto.randomUUID(), filepath: context.document.uri.fsPath, + languageId: context.document.languageId, pos: { line: position.line, character: position.character }, recentlyVisitedRanges, recentlyEditedRanges, diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index b6755004547..9ff83a941fc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -31,6 +31,8 @@ export interface KilocodeProfile { email: string name?: string organizations?: KilocodeOrganization[] + selectedOrganizationId?: string + hasPersonalAccount?: boolean } export interface KilocodeBalance { diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index ecb8cd2e4a8..ac33d13296b 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "../cli-backend/connection-service" import { getErrorMessage } from "../../kilo-provider-utils" +import { getCommitMessageLanguage } from "../i18n" let lastGeneratedMessage: string | undefined let lastWorkspacePath: string | undefined @@ -94,7 +95,7 @@ export function registerCommitMessageService( try { const { data } = await client.commitMessage.generate( - { path, selectedFiles: undefined, previousMessage }, + { path, selectedFiles: undefined, previousMessage, language: getCommitMessageLanguage(vscode) }, { throwOnError: true, signal: controller.signal }, ) const message = data.message @@ -119,7 +120,7 @@ export function registerCommitMessageService( } const msg = getErrorMessage(error) console.error("[Kilo New] Failed to generate commit message:", msg) - vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`) + vscode.window.showErrorMessage(msg || "Failed to generate commit message. Please try again.") }) }, ) diff --git a/packages/kilo-vscode/src/services/i18n/index.ts b/packages/kilo-vscode/src/services/i18n/index.ts index 0082c2f9026..4da8dda2921 100644 --- a/packages/kilo-vscode/src/services/i18n/index.ts +++ b/packages/kilo-vscode/src/services/i18n/index.ts @@ -66,6 +66,13 @@ export function selectedLocale(vscode: typeof import("vscode")): string { return resolveLocale(lang || vscode.env.language) } +export function getCommitMessageLanguage(vscode: typeof import("vscode")): string { + const cfg = vscode.workspace.getConfiguration("kilo-code.new") + const commitLang = cfg.get("languageCommitMessage") ?? "sync" + if (commitLang === "sync") return selectedLocale(vscode) + return resolveLocale(commitLang) +} + export function translate( locale: string, key: keyof typeof enDict | string, diff --git a/packages/kilo-vscode/src/services/input-tools.ts b/packages/kilo-vscode/src/services/input-tools.ts index 7d722b684ed..76157c11ff2 100644 --- a/packages/kilo-vscode/src/services/input-tools.ts +++ b/packages/kilo-vscode/src/services/input-tools.ts @@ -1,6 +1,7 @@ import type { KiloConnectionService } from "./cli-backend/connection-service" import { routeAutocompleteMessage } from "./autocomplete/settings" import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler" +import { prewarmSpeechCapture } from "../speech-to-text/capture" type Msg = { type: string @@ -18,6 +19,11 @@ type Ctx = { export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise { if (await routeAutocompleteMessage(message, ctx.post)) return true + if (message.type === "speechToTextPrewarm") { + void prewarmSpeechCapture().catch((err: unknown) => console.warn("[Kilo New] Speech capture prewarm failed:", err)) + return true + } + if (message.type === "speechToTextStart") { if (!message.requestId) return true handleSpeechToTextStart( diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts index 03efa67aaff..6250c0822c1 100644 --- a/packages/kilo-vscode/src/speech-to-text/capture.ts +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -36,13 +36,18 @@ type Args = { let active: Recording | undefined let starting: string | undefined +let ffmpeg: Promise | undefined + +export async function prewarmSpeechCapture(): Promise { + await resolveFFmpeg() +} export async function startSpeechCapture(input: Input): Promise { if (active || starting) throw new Error("Speech recording is already in progress") starting = input.requestId try { - const bin = await findFFmpeg() + const bin = await resolveFFmpeg() const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`) const state = await startWithArgs(bin, file, input, await inputArgSets(bin)) return !state.stopped @@ -229,6 +234,23 @@ function requireActive(requestId: string): Recording { return active } +async function resolveFFmpeg(): Promise { + const cached = ffmpeg + if (cached) { + const bin = await cached + if (!path.isAbsolute(bin) || existsSync(bin)) return bin + } + + const task = findFFmpeg() + ffmpeg = task + try { + return await task + } catch (err) { + if (ffmpeg === task) ffmpeg = undefined + throw err + } +} + async function findFFmpeg(): Promise { const paths = [ process.env.KILO_FFMPEG_PATH, diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 5cb1d097410..d85485821f4 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -50,8 +50,13 @@ const mockVscode = { notebookDocuments: [] as Array, onDidOpenTextDocument: () => ({ dispose: noop }), onDidChangeTextDocument: () => ({ dispose: noop }), + onDidSaveTextDocument: () => ({ dispose: noop }), onDidCloseTextDocument: () => ({ dispose: noop }), onDidChangeConfiguration: () => ({ dispose: noop }), + openTextDocument: async (input: { content?: string; language?: string }) => ({ + getText: () => input.content ?? "", + languageId: input.language ?? "plaintext", + }), getConfiguration: () => ({ get: (_key: string, value?: T) => value, update: async () => {}, @@ -81,6 +86,7 @@ const mockVscode = { tabGroups: { all: [] }, showTextDocument: async () => {}, showInformationMessage: async () => undefined, + showErrorMessage: async () => undefined, showWarningMessage: async () => undefined, createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }), createOutputChannel: () => ({ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts new file mode 100644 index 00000000000..1ac56160976 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-memory-commands.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, mock } from "bun:test" + +const { AgentManagerProvider } = await import("../../src/agent-manager/AgentManagerProvider") + +type Manager = { + host: { showError: (message: string) => void } + panel: + | { + waitForReady: () => Promise + onDidDispose: (cb: () => void) => { dispose: () => void } + sessions: { + showMemory: (sessionID?: string) => Promise + toggleMemory: (sessionID?: string) => Promise + } + } + | undefined + activeSessionId: string | undefined + showMemory: () => Promise + toggleMemory: () => Promise +} + +function deferred() { + let done: (() => void) | undefined + const promise = new Promise((resolve) => { + done = resolve + }) + return { + promise, + resolve: () => done?.(), + } +} + +function manager() { + const calls: unknown[] = [] + const disposers: Array<() => void> = [] + const item = Object.create(AgentManagerProvider.prototype) as Manager + item.host = { showError: mock((message) => calls.push(["error", message])) } + item.panel = { + waitForReady: mock(async () => calls.push("ready")), + onDidDispose: mock((cb) => { + disposers.push(cb) + return { dispose: mock(() => {}) } + }), + sessions: { + showMemory: mock(async (sessionID) => calls.push(["show", sessionID])), + toggleMemory: mock(async (sessionID) => calls.push(["toggle", sessionID])), + }, + } + item.activeSessionId = "ses_agent_manager" + return { + item, + calls, + dispose: () => { + for (const cb of disposers) cb() + }, + } +} + +describe("AgentManagerProvider memory commands", () => { + it("routes memory commands to the active Agent Manager session", async () => { + const ctx = manager() + + await ctx.item.showMemory() + await ctx.item.toggleMemory() + + expect(ctx.calls).toEqual(["ready", ["show", "ses_agent_manager"], "ready", ["toggle", "ses_agent_manager"]]) + }) + + it("does not fall back when Agent Manager has no active session", async () => { + const ctx = manager() + ctx.item.activeSessionId = undefined + + await ctx.item.showMemory() + + expect(ctx.calls).toEqual([["error", "No active Agent Manager session"]]) + }) + + it("does not hang when the panel is disposed before it is ready", async () => { + const ctx = manager() + const wait = deferred() + ctx.item.panel!.waitForReady = mock(() => wait.promise) + + const pending = ctx.item.showMemory() + ctx.dispose() + await pending + + expect(ctx.calls).toEqual([]) + }) + + it("does not target a stale session after readiness resolves", async () => { + const ctx = manager() + const wait = deferred() + ctx.item.panel!.waitForReady = mock(() => wait.promise) + + const pending = ctx.item.toggleMemory() + ctx.item.activeSessionId = "ses_other" + wait.resolve() + await pending + + expect(ctx.calls).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts new file mode 100644 index 00000000000..f4f417fad0a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" + +// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) +const { KiloProvider } = await import("../../src/KiloProvider") + +type Internals = { + currentSession: { id: string } | null + trackedSessionIds: Set + webview: { postMessage(message: unknown): Promise } | null + handleEvent(event: unknown, directory?: string): void + memory: { idle(): Promise } +} + +function status(root: string) { + return { + root: `${root}/.kilo/memory`, + state: { + enabled: true, + autoConsolidate: true, + stats: { + lastInjectedSessionID: "", + lastInjectedTokens: 0, + lastOperationCount: 0, + }, + }, + index: { estimatedTokens: 0 }, + } +} + +function show(root: string) { + return { + root: `${root}/.kilo/memory`, + state: status(root).state, + sources: { project: "", environment: "", corrections: "" }, + index: "", + items: "", + changes: "", + decisions: "", + } +} + +describe("KiloProvider memory events", () => { + it("routes tracked background memory events to their session directory", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + item.trackedSessionIds.add("ses_bg") + provider.setSessionDirectory("ses_bg", "/worktree") + + item.handleEvent( + { + type: "memory.updated", + properties: { + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }, + }, + "/worktree", + ) + await item.memory.idle() + + expect(posts).toContainEqual({ + type: "memoryEvent", + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_bg" })) + expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent", sessionID: "ses_active" })) + expect(calls).toEqual(["/worktree"]) + }) + + it("also refreshes the active session for same-directory memory events", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + item.trackedSessionIds.add("ses_bg") + provider.setSessionDirectory("ses_active", "/repo") + provider.setSessionDirectory("ses_bg", "/repo") + + item.handleEvent( + { + type: "memory.updated", + properties: { + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }, + }, + "/repo", + ) + await item.memory.idle() + + expect(posts).toContainEqual({ + type: "memoryEvent", + sessionID: "ses_bg", + detail: { type: "saved", message: "Saved project memory" }, + }) + expect(posts).toContainEqual({ + type: "memoryEvent", + sessionID: "ses_active", + detail: { type: "saved", message: "Saved project memory" }, + }) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_bg" })) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + expect(calls).toEqual(["/repo", "/repo"]) + }) + + it("uses the project directory when toggling memory", async () => { + const calls: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(["status", input.directory]) + return { data: status(input.directory) } + }, + disable: async (input: { directory: string }) => { + calls.push(["disable", input.directory]) + return { data: { root: `${input.directory}/.kilo/memory`, state: status(input.directory).state } } + }, + show: async (input: { directory: string }) => { + calls.push(["show", input.directory]) + return { data: show(input.directory) } + }, + }, + } as unknown as KiloClient + const posts: unknown[] = [] + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + undefined, + { projectDirectory: "/repo/project" }, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + + await provider.toggleMemory("ses_active") + + expect(calls).toEqual([ + ["status", "/repo/project"], + ["disable", "/repo/project"], + ["status", "/repo/project"], + ["show", "/repo/project"], + ]) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts new file mode 100644 index 00000000000..3630e807907 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { KiloProviderMemory } from "../../src/kilo-provider/memory" + +function subject(client: KiloClient | undefined) { + const posts: unknown[] = [] + const memory = new KiloProviderMemory({ + client: () => client, + session: () => undefined, + dir: () => "/repo", + post: (message) => posts.push(message), + }) + return { memory, posts } +} + +function status(root: string) { + return { + root: `${root}/.kilo/memory`, + state: { + enabled: true, + autoConsolidate: true, + stats: { + lastInjectedSessionID: "", + lastInjectedTokens: 0, + lastOperationCount: 0, + }, + }, + index: { estimatedTokens: 0 }, + } +} + +function show(root: string) { + return { + root: `${root}/.kilo/memory`, + state: status(root).state, + sources: { project: "", environment: "", corrections: "" }, + index: "", + items: "", + changes: "", + decisions: "", + } +} + +describe("KiloProviderMemory", () => { + it("handles clients without memory endpoints gracefully", async () => { + const item = subject({} as KiloClient) + + await item.memory.fetch("ses_memoryless") + await item.memory.show("ses_memoryless") + await item.memory.run({ operation: "enable", sessionID: "ses_memoryless" }) + + expect(item.posts).toEqual([ + { + type: "memoryLoaded", + sessionID: "ses_memoryless", + error: "Memory unavailable in CLI backend", + }, + { + type: "memoryLoaded", + sessionID: "ses_memoryless", + error: "Memory unavailable in CLI backend", + }, + { + type: "memoryOperationResult", + operation: "enable", + sessionID: "ses_memoryless", + ok: false, + error: "Memory unavailable in CLI backend", + }, + ]) + }) + + it("posts a load error when no client or cache exists", async () => { + const item = subject(undefined) + + await item.memory.fetch("ses_disconnected") + + expect(item.posts).toEqual([ + { + type: "memoryLoaded", + sessionID: "ses_disconnected", + error: "Not connected to CLI backend", + }, + ]) + }) + + it("evicts older cached memory payloads", async () => { + let client: KiloClient | undefined + const posts: unknown[] = [] + const item = new KiloProviderMemory({ + client: () => client, + session: () => undefined, + dir: (sid) => `/repo/${sid ?? "current"}`, + post: (message) => posts.push(message), + }) + client = { + memory: { + status: async (input: { directory: string }) => ({ data: status(input.directory) }), + show: async (input: { directory: string }) => ({ data: show(input.directory) }), + }, + } as unknown as KiloClient + + for (let i = 0; i < 9; i++) { + await item.show(`ses_${i}`) + } + + posts.length = 0 + client = undefined + await item.fetch("ses_0") + await item.fetch("ses_8") + + expect(posts[0]).toEqual({ + type: "memoryLoaded", + sessionID: "ses_0", + error: "Not connected to CLI backend", + }) + expect(posts[1]).toMatchObject({ + type: "memoryLoaded", + sessionID: "ses_8", + show: { root: "/repo/ses_8/.kilo/memory" }, + }) + }) + + it("does not send ignored placement fields to correction endpoint", async () => { + const calls: unknown[] = [] + const state = status("/repo") + const view = show("/repo") + const item = subject({ + memory: { + correct: async (input: unknown) => { + calls.push(input) + return { data: { operationCount: 1, added: 1, removed: 0, skipped: [], index: { tokens: 0 } } } + }, + status: async () => ({ data: state }), + show: async () => ({ data: view }), + }, + } as unknown as KiloClient) + + await item.memory.run({ + operation: "correct", + sessionID: "ses_correct", + text: "Prefer corrections.", + key: "correction_key", + file: "project.md", + section: "Facts", + }) + + expect(calls).toEqual([ + { + directory: "/repo", + text: "Prefer corrections.", + key: "correction_key", + sessionID: "ses_correct", + }, + ]) + }) + + it("routes status operations without mutating memory", async () => { + const calls: string[] = [] + const state = status("/repo") + const view = show("/repo") + const item = subject({ + memory: { + status: async () => { + calls.push("status") + return { data: state } + }, + show: async () => { + calls.push("show") + return { data: view } + }, + }, + } as unknown as KiloClient) + + await item.memory.run({ operation: "status", sessionID: "ses_memory" }) + + expect(calls).toEqual(["status", "status", "show"]) + expect(item.posts).toContainEqual( + expect.objectContaining({ type: "memoryOperationResult", operation: "status", ok: true }), + ) + }) + + it("routes auto-save and purge operations with explicit payloads", async () => { + const calls: unknown[] = [] + const state = status("/repo") + const view = show("/repo") + state.state.autoConsolidate = false + const item = subject({ + memory: { + configure: async (input: unknown) => { + calls.push(["configure", input]) + return { data: { root: "/repo/.kilo/memory", state: state.state } } + }, + purge: async (input: unknown) => { + calls.push(["purge", input]) + return { data: { root: "/repo/.kilo/memory", purged: true } } + }, + status: async () => ({ data: state }), + show: async () => ({ data: view }), + }, + } as unknown as KiloClient) + + await item.memory.run({ operation: "auto", mode: "off", sessionID: "ses_memory" }) + await item.memory.run({ operation: "purge", confirm: true, sessionID: "ses_memory" }) + + expect(calls).toEqual([ + ["configure", { directory: "/repo", autoConsolidate: false }], + ["purge", { directory: "/repo", confirm: true }], + ]) + expect(item.posts.filter((post) => (post as { type?: string }).type === "memoryOperationResult")).toHaveLength(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/memory-command.test.ts b/packages/kilo-vscode/tests/unit/memory-command.test.ts new file mode 100644 index 00000000000..1bd1df1e5d9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/memory-command.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test" +import { parseMemoryCommand, type ParsedMemoryCommand } from "../../webview-ui/src/utils/memory-command" + +type MemoryOperation = + | "enable" + | "status" + | "edit" + | "disable" + | "rebuild" + | "remember" + | "correct" + | "forget" + | "purge" + | "auto" +type Case = { + name: string + input: string + result: "none" | "help" | "show" | "operation" | "usage" + operation?: MemoryOperation + mode?: "status" | "on" | "off" + confirm?: boolean + text?: string + query?: string + reason?: string +} + +const cases = (await Bun.file( + new URL("../../../kilo-memory/test/command-cases.json", import.meta.url), +).json()) as Case[] + +function expected(item: Case): ParsedMemoryCommand | undefined { + if (item.result === "none") return + if (item.result === "help") return { kind: "help" } + if (item.result === "show") return { kind: "show" } + if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" } + if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`) + if (item.operation === "remember" || item.operation === "correct") { + if (!item.text) throw new Error(`Missing text for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, text: item.text } + } + if (item.operation === "forget") { + if (!item.query) throw new Error(`Missing query for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, query: item.query } + } + if (item.operation === "auto") { + if (!item.mode) throw new Error(`Missing mode for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, mode: item.mode } + } + if (item.operation === "purge") { + if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, confirm: true } + } + return { kind: "operation", operation: item.operation } +} + +describe("parseMemoryCommand", () => { + it("matches shared command fixtures", () => { + for (const item of cases) { + const parsed = parseMemoryCommand(item.input) + if (item.result === "usage") { + expect(parsed?.kind, item.name).toBe("usage") + expect(parsed && "reason" in parsed ? parsed.reason : "", item.name).toContain(item.reason ?? "") + continue + } + expect(parsed, item.name).toEqual(expected(item)) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-usage.test.ts b/packages/kilo-vscode/tests/unit/model-usage.test.ts index 4d1c84c981c..4b6461f5308 100644 --- a/packages/kilo-vscode/tests/unit/model-usage.test.ts +++ b/packages/kilo-vscode/tests/unit/model-usage.test.ts @@ -48,6 +48,8 @@ describe("model usage", () => { expect(modelUsageName(models[0], providers)).toBe("Qwen 3.7 Plus") expect(modelUsageName(models[1], providers)).toBe("MiniMax M3") expect(modelUsageName({ ...models[0], modelID: "moonshotai/kimi-k2.7-code-20260612" }, {})).toBe("kimi-k2.7-code") + // Routed free-variant ids keep their name instead of collapsing to the ":free" suffix + expect(modelUsageName({ ...models[0], modelID: "tencent/hy3:free" }, {})).toBe("hy3:free") }) test("matches sessions through their top-level tree", () => { diff --git a/packages/kilo-vscode/tests/unit/notebook-context.test.ts b/packages/kilo-vscode/tests/unit/notebook-context.test.ts index c4f2742936c..99002dd05a4 100644 --- a/packages/kilo-vscode/tests/unit/notebook-context.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-context.test.ts @@ -7,7 +7,9 @@ import { supportsNotebook, } from "../../src/services/autocomplete/continuedev/core/autocomplete/notebook" import { accessible } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider" +import { constructInitialPrefixSuffix } from "../../src/services/autocomplete/continuedev/core/autocomplete/templating/constructPrefixSuffix" import type { FileIgnoreController } from "../../src/services/autocomplete/shims/FileIgnoreController" +import type { AutocompleteInput } from "../../src/services/autocomplete/types" function uri(scheme: string, path: string, fragment = ""): vscode.Uri { const value = `${scheme}:${path}${fragment ? `#${fragment}` : ""}` @@ -38,8 +40,8 @@ function notebooks(value: vscode.NotebookDocument[]): void { describe("notebook context", () => { beforeEach(() => notebooks([])) - it("flattens notebook cells and translates the cursor", () => { - const markdown = document("markdown", "# Title\nNotes") + it("projects mixed-language context for the active Python cell", () => { + const markdown = document("markdown", "# Title\nNotes", "markdown") const code = document("code", "const value = 1\nvalue += 1", "javascript") const current = document("current", "print(value)\nprint('done')") const notebook = { @@ -55,34 +57,104 @@ describe("notebook context", () => { const context = getNotebookContext(current, new vscode.Position(1, 5)) expect(context).toEqual({ - contents: `"""# Title\nNotes"""\n\nconst value = 1\nvalue += 1\n\nprint(value)\nprint('done')`, + contents: `# [markdown] # Title\n# Notes\n\n# [javascript] const value = 1\n# value += 1\n\nprint(value)\nprint('done')`, filepath: "/workspace/example.ipynb", position: new vscode.Position(7, 5), }) }) - it("limits notebook completion to Python code cells", () => { - const python = document("python", "value = 1") - const javascript = document("javascript", "const value = 1", "javascript") - const markdown = document("markdown", "# Heading", "markdown") + it("projects mixed-language context for the active JavaScript cell", () => { + const markdown = document("markdown", "Setup\nvalues", "markdown") + const python = document("python", "value = 1\nprint(value)") + const current = document("current", "const value = 1", "javascript") const notebook = { uri: uri("file", "/workspace/example.ipynb"), getCells: () => [ - { kind: vscode.NotebookCellKind.Code, document: python }, - { kind: vscode.NotebookCellKind.Code, document: javascript }, { kind: vscode.NotebookCellKind.Markup, document: markdown }, + { kind: vscode.NotebookCellKind.Code, document: python }, + { kind: vscode.NotebookCellKind.Code, document: current }, ], } as vscode.NotebookDocument notebooks([notebook]) - expect(supportsNotebook(python)).toBe(true) - expect(supportsNotebook(javascript)).toBe(false) - expect(supportsNotebook(markdown)).toBe(false) - expect(getNotebookContext(javascript, new vscode.Position(0, 0))).toBeUndefined() - expect(getNotebookContext(markdown, new vscode.Position(0, 0))).toBeUndefined() + expect(getNotebookContext(current, new vscode.Position(0, 6))).toEqual({ + contents: `// [markdown] Setup\n// values\n\n// [python] value = 1\n// print(value)\n\nconst value = 1`, + filepath: "/workspace/example.ipynb", + position: new vscode.Position(6, 6), + }) + }) + + it("supports known code languages and rejects non-code or unknown cells", () => { + const cells = [ + document("python", "value = 1"), + document("javascript", "const value = 1", "javascript"), + document("typescript", "const value: number = 1", "typescript"), + document("r", "value <- 1", "r"), + document("julia", "value = 1", "julia"), + document("jsonc", "{ // comment\n}", "jsonc"), + document("luau", "local value = 1", "luau"), + document("unknown", "value = 1", "custom-language"), + document("markdown", "# Heading", "markdown"), + ] + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => + cells.map((document, index) => ({ + kind: index === cells.length - 1 ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code, + document, + })), + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(cells.slice(0, 7).every(supportsNotebook)).toBe(true) + expect(supportsNotebook(cells[7]!)).toBe(false) + expect(supportsNotebook(cells[8]!)).toBe(false) + expect(getNotebookContext(cells[7]!, new vscode.Position(0, 0))).toBeUndefined() expect(supportsNotebook({ uri: uri("file", "/workspace/file.ts") } as vscode.TextDocument)).toBe(true) }) + it("omits foreign and markup content from strict JSON context", () => { + const markdown = document("markdown", "Describe values", "markdown") + const javascript = document("javascript", "const value = 1", "javascript") + const sibling = document("sibling", '{"other": 2}', "json") + const current = document("current", '{"value": 1}', "json") + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => [ + { kind: vscode.NotebookCellKind.Markup, document: markdown }, + { kind: vscode.NotebookCellKind.Code, document: javascript }, + { kind: vscode.NotebookCellKind.Code, document: sibling }, + { kind: vscode.NotebookCellKind.Code, document: current }, + ], + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(getNotebookContext(current, new vscode.Position(0, 3))).toEqual({ + contents: `\n\n\n\n\n\n{"value": 1}`, + filepath: "/workspace/example.ipynb", + position: new vscode.Position(6, 3), + }) + }) + + it("uses the active cell language when constructing notebook prompts", async () => { + const input: AutocompleteInput = { + isUntitledFile: false, + completionId: "completion", + filepath: "/workspace/example.ipynb", + languageId: "javascript", + pos: { line: 0, character: 5 }, + recentlyVisitedRanges: [], + recentlyEditedRanges: [], + manuallyPassFileContents: "value = 1", + injectDetails: "notebook context", + } + + const result = await constructInitialPrefixSuffix(input, {} as never) + + expect(result.prefix).toBe("\n// notebook context\nvalue") + expect(result.suffix).toBe(" = 1") + }) + it("resolves file and notebook cell URIs", () => { const file = uri("file", "/workspace/file.ts") const cell = document("code", "value = 1") @@ -120,6 +192,11 @@ describe("notebook context", () => { Object.assign(notebook, { version: 3 }) expect(autocompleteScope(current)).not.toBe(initial) expect(autocompleteScope(current)).not.toBe(autocompleteScope(sibling)) + + const changed = autocompleteScope(current) + Object.assign(current, { languageId: "javascript" }) + Object.assign(notebook, { version: 4 }) + expect(autocompleteScope(current)).not.toBe(changed) }) it("changes autocomplete scope when sibling order changes", () => { diff --git a/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts index bffa6a14563..cea96c4bc35 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts @@ -4,14 +4,26 @@ import { join } from "node:path" const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx") const src = readFileSync(path, "utf8") +const cssPath = join(__dirname, "..", "..", "webview-ui", "src", "styles", "prompt-input.css") +const css = readFileSync(cssPath, "utf8") +const dialogPath = join(__dirname, "..", "..", "webview-ui", "agent-manager", "NewWorktreeDialog.tsx") +const dialog = readFileSync(dialogPath, "utf8") describe("PromptInput bidirectional text support", () => { it("lets the textarea and visible overlay resolve text direction automatically", () => { const overlay = src.match(/
/)?.[0] + const overlayCss = css.match(/\.prompt-input-highlight-overlay\s*{[^}]*}/)?.[0] const input = src.match(//)?.[0] expect(overlay).toContain('dir="auto"') + expect(overlayCss).toContain("unicode-bidi: plaintext") expect(input).toContain('class="prompt-input"') expect(input).toContain('dir="auto"') }) + + it("lets the Agent Manager worktree prompt resolve bidirectional text automatically", () => { + const input = dialog.match(//)?.[0] + + expect(input).toContain('dir="auto"') + }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 0aee37e70bf..cc79151a3e3 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -213,12 +213,45 @@ describe("sendMessage / sendCommand draft id contract", () => { // from ":pending:" to ":session:". The user loses the // typed message and the new session starts empty. const body = extractFunctionBody(source, "sendMessage") - expect(body).toMatch(/!sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + expect(body).toMatch(/const effectiveDraftID = !sid && !draftID \? crypto\.randomUUID\(\) : draftID/) }) it("sendCommand mints a draftID when there is no current session and none was supplied", () => { const body = extractFunctionBody(source, "sendCommand") - expect(body).toMatch(/!sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + expect(body).toMatch(/const effectiveDraftID = !sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + }) + + it("sendMessage seeds the pending agent before resolving the draft-scoped agent", () => { + // Fresh draft IDs are created after ModeSwitcher stored the selected mode in + // pendingAgentSelection(). The draft scope must inherit that pending agent + // before promptAgent(scope) runs, otherwise the first send pairs the selected + // model with the default agent's system prompt. + const body = extractFunctionBody(source, "sendMessage") + expect(body).toMatch( + /if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*const agent = promptAgent\(scope\)/, + ) + }) + + it("sendCommand seeds the pending agent before resolving the draft-scoped agent", () => { + const body = extractFunctionBody(source, "sendCommand") + expect(body).toMatch( + /if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*const agent = promptAgent\(scope\)/, + ) + }) + + it("does not clear a newer pending agent when a seeded draft is promoted", () => { + const body = extractFunctionBody(source, "handleSessionCreated") + const draftBlock = body.match(/if \(draftID\) \{([\s\S]*?)\} else if/) + expect(draftBlock).not.toBeNull() + expect(draftBlock![1]).not.toContain("setPendingAgentSelection(null)") + }) + + it("prunes seeded draft agents only after the draft is abandoned", () => { + const failed = extractFunctionBody(source, "handleSendMessageFailed") + expect(source).toMatch(/const agentDrafts = createDraftAgentSeed/) + expect(source).toContain("active: (draft) => !!submissionMap[draft]") + expect(failed).toContain("draftSessionID() !== message.draftID") + expect(failed).toContain("agentDrafts.prune(message.draftID)") }) }) diff --git a/packages/kilo-vscode/tests/unit/session-agent.test.ts b/packages/kilo-vscode/tests/unit/session-agent.test.ts index 9535e0f0951..d72839a6f80 100644 --- a/packages/kilo-vscode/tests/unit/session-agent.test.ts +++ b/packages/kilo-vscode/tests/unit/session-agent.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "bun:test" -import { resolveSessionAgent } from "../../webview-ui/src/context/session-agent" +import { + createDraftAgentSeed, + draftAgentSelection, + resolveSessionAgent, +} from "../../webview-ui/src/context/session-agent" import type { Message } from "../../webview-ui/src/types/messages" function makeMessage(overrides: Partial = {}): Message { @@ -76,3 +80,83 @@ describe("resolveSessionAgent", () => { expect(result).toBeUndefined() }) }) + +describe("draftAgentSelection", () => { + it("carries a pending agent into a new draft scope", () => { + const result = draftAgentSelection({}, "draft-1", "plan") + + expect(result).toBe("plan") + }) + + it("does not overwrite an existing draft agent", () => { + const result = draftAgentSelection({ "draft-1": "code" }, "draft-1", "plan") + + expect(result).toBeUndefined() + }) + + it("ignores missing pending agents", () => { + const result = draftAgentSelection({}, "draft-1", null) + + expect(result).toBeUndefined() + }) +}) + +describe("createDraftAgentSeed", () => { + it("seeds and prunes abandoned draft agents", () => { + const selections: Record = {} + const seed = createDraftAgentSeed({ + selections: () => selections, + pending: () => "plan", + active: () => false, + set: (draft, agent) => { + selections[draft] = agent + }, + drop: (draft) => { + delete selections[draft] + }, + }) + + seed.seed("draft-1") + expect(selections["draft-1"]).toBe("plan") + + seed.prune("draft-1") + expect(selections["draft-1"]).toBeUndefined() + }) + + it("keeps active drafts available for retry", () => { + const selections: Record = {} + const seed = createDraftAgentSeed({ + selections: () => selections, + pending: () => "code", + active: () => true, + set: (draft, agent) => { + selections[draft] = agent + }, + drop: (draft) => { + delete selections[draft] + }, + }) + + seed.seed("draft-1") + seed.prune("draft-1") + + expect(selections["draft-1"]).toBe("code") + }) + + it("promotes drafts without dropping the migrated agent", () => { + const dropped: string[] = [] + const seed = createDraftAgentSeed({ + selections: () => ({}), + pending: () => "ask", + active: () => false, + set: () => {}, + drop: (draft) => dropped.push(draft), + }) + + seed.seed("draft-1") + seed.promote("draft-1") + seed.prune("draft-1") + + expect(dropped).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts new file mode 100644 index 00000000000..db91dde8f7d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test" +import path from "node:path" + +// Run from webview-ui so the child transpiles JSX with solid-js (its tsconfig sets +// jsxImportSource: solid-js). The package root tsconfig has no jsx setting, which makes +// bun fall back to react/jsx-dev-runtime and fail whenever react isn't hoisted nearby. +const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui") + +// solid-js effects only run under the browser export condition, so the component has to be +// exercised in a child process resolved with `--conditions=browser`. The child prints an +// explicit PASS/FAIL sentinel: a FAIL means the prewarm logic is wrong (fail immediately), +// while any other non-zero exit is a transient spawn failure under load and is retried so +// the suite stays deterministic. +const PASS = "PREWARM_PASS" +const FAIL = "PREWARM_FAIL:" + +const SCRIPT = ` + import { Window } from "happy-dom" + + const window = new Window() + globalThis.window = window + globalThis.document = window.document + globalThis.Node = window.Node + + const sent = [] + globalThis.acquireVsCodeApi = () => ({ + postMessage: (message) => sent.push(message), + getState: () => undefined, + setState: () => {}, + }) + + const { createComponent, createSignal } = await import("solid-js") + const { render } = await import("solid-js/web") + const { ConfigContext } = await import("./src/context/config.tsx") + const { ProviderContext } = await import("./src/context/provider.tsx") + const { SpeechToTextPrewarm } = await import( + "./src/components/speech-to-text/SpeechToTextPrewarm.tsx" + ) + + const fail = (reason) => { + console.log("${FAIL}" + reason) + process.exit(2) + } + + const [config, setConfig] = createSignal({ disabled_providers: ["kilo"] }) + const [auth, setAuth] = createSignal({}) + const root = document.createElement("div") + const dispose = render( + () => + createComponent(ProviderContext.Provider, { + value: { authStates: auth }, + get children() { + return createComponent(ConfigContext.Provider, { + value: { config }, + get children() { + return createComponent(SpeechToTextPrewarm, {}) + }, + }) + }, + }), + root, + ) + + if (sent.length !== 0) fail("prewarmed without Kilo access") + setAuth({ kilo: "api" }) + if (sent.length !== 0) fail("prewarmed while Kilo was disabled") + setConfig({}) + if (sent.length !== 1 || sent[0]?.type !== "speechToTextPrewarm") { + fail("did not prewarm after Kilo access became available") + } + setAuth({ kilo: "oauth" }) + if (sent.length !== 1) fail("prewarmed more than once") + dispose() + console.log("${PASS}") +` + +describe("speech-to-text prewarm", () => { + it("starts only after Kilo speech access becomes available", () => { + const attempts = 3 + const failures: string[] = [] + + for (let attempt = 1; attempt <= attempts; attempt++) { + const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], { + cwd: WEBVIEW, + stdout: "pipe", + stderr: "pipe", + }) + const output = result.stdout.toString() + result.stderr.toString() + + if (output.includes(PASS)) return + + const logic = output.indexOf(FAIL) + // A FAIL sentinel is a real assertion failure in the prewarm logic — surface it now. + if (logic !== -1) { + expect.unreachable( + output + .slice(logic + FAIL.length) + .split("\n")[0] + ?.trim(), + ) + } + + // Otherwise the child died before it could run (starved/transient spawn) — retry. + failures.push(`attempt ${attempt} exit ${result.exitCode}: ${output.trim() || ""}`) + } + + expect.unreachable(`prewarm child never reported success:\n${failures.join("\n")}`) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts index cb06763a7d8..3d8027e18a7 100644 --- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -5,19 +5,29 @@ import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/type const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -function textarea(value: string, cursor: number, dir: "ltr" | "rtl") { - const state = { cursor } +function textarea( + value: string, + cursor: number, + dir: "ltr" | "rtl", + selection?: { end: number; direction: SelectionDirection }, +) { + const state = { start: cursor, end: selection?.end ?? cursor, direction: selection?.direction ?? "none" } return { value, get selectionStart() { - return state.cursor + return state.start }, get selectionEnd() { - return state.cursor + return state.end + }, + get selectionDirection() { + return state.direction }, matches: (selector: string) => selector === `:dir(${dir})`, - setSelectionRange: (start: number) => { - state.cursor = start + setSelectionRange: (start: number, end = start, direction: SelectionDirection = "none") => { + state.start = start + state.end = end + state.direction = direction }, } as unknown as HTMLTextAreaElement } @@ -215,7 +225,7 @@ describe("useFileMention", () => { dispose.fn?.() }) - it("keeps mention block arrow navigation aligned with left-to-right prompt direction", async () => { + it("snaps a native forward caret move over a mention", async () => { const ctx = { postMessage: () => {}, onMessage: () => () => {}, @@ -232,19 +242,27 @@ describe("useFileMention", () => { const right = key("ArrowRight") const input = textarea(text, "See ".length, "ltr") - expect(mention.handleArrowKey(right.event, input)).toBe(true) + expect(mention.handleArrowKey(right.event, input)).toBe(false) + expect(right.state.prevented).toBe(0) + + const positionAfterArrowRight = "See @".length + input.setSelectionRange(positionAfterArrowRight, positionAfterArrowRight) + await wait(0) expect(input.selectionStart).toBe("See @src/main.ts".length) - expect(right.state.prevented).toBe(1) const left = key("ArrowLeft") - expect(mention.handleArrowKey(left.event, input)).toBe(true) + expect(mention.handleArrowKey(left.event, input)).toBe(false) + expect(left.state.prevented).toBe(0) + + const positionAfterArrowLeft = "See @src/main.t".length + input.setSelectionRange(positionAfterArrowLeft, positionAfterArrowLeft) + await wait(0) expect(input.selectionStart).toBe("See ".length) - expect(left.state.prevented).toBe(1) dispose.fn?.() }) - it("keeps mention block arrow navigation aligned for right-to-left languages", async () => { + it("snaps a native right-to-left caret move over a mention", async () => { const ctx = { postMessage: () => {}, onMessage: () => () => {}, @@ -261,14 +279,119 @@ describe("useFileMention", () => { const left = key("ArrowLeft") const input = textarea(text, "فایل ".length, "rtl") - expect(mention.handleArrowKey(left.event, input)).toBe(true) + expect(mention.handleArrowKey(left.event, input)).toBe(false) + expect(left.state.prevented).toBe(0) + + const positionAfterArrowLeft = "فایل @".length + input.setSelectionRange(positionAfterArrowLeft, positionAfterArrowLeft) + await wait(0) expect(input.selectionStart).toBe("فایل @src/main.ts".length) - expect(left.state.prevented).toBe(1) const right = key("ArrowRight") - expect(mention.handleArrowKey(right.event, input)).toBe(true) + expect(mention.handleArrowKey(right.event, input)).toBe(false) + expect(right.state.prevented).toBe(0) + + const positionAfterArrowRight = "فایل @src/main.t".length + input.setSelectionRange(positionAfterArrowRight, positionAfterArrowRight) + await wait(0) expect(input.selectionStart).toBe("فایل ".length) - expect(right.state.prevented).toBe(1) + + dispose.fn?.() + }) + + it("resolves a pending arrow snap before the next native arrow move", async () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const text = "See @src/main.ts now" + mention.addPaths(["src/main.ts"], "/repo") + + const right = key("ArrowRight") + const input = textarea(text, "See ".length, "ltr") + expect(mention.handleArrowKey(right.event, input)).toBe(false) + + const pos = "See @".length + input.setSelectionRange(pos, pos) + expect(mention.handleArrowKey(right.event, input)).toBe(false) + + const end = "See @src/main.ts".length + expect(input.selectionStart).toBe(end) + + input.setSelectionRange(end + 1, end + 1) + await wait(0) + expect(input.selectionStart).toBe(end + 1) + + dispose.fn?.() + }) + + it("shrinks a left-to-right shift selection across a mention", async () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const path = "README.md" + const text = `A @${path} B` + const start = text.indexOf("@") + const end = start + path.length + 1 + mention.addPaths([path], "/repo") + + const input = textarea(text, end - 1, "ltr", { end: text.length, direction: "backward" }) + mention.snapSelection(input) + expect(input.selectionStart).toBe(start) + expect(input.selectionEnd).toBe(text.length) + + input.setSelectionRange(start + 1, text.length, "backward") + mention.snapSelection(input) + expect(input.selectionStart).toBe(end) + expect(input.selectionEnd).toBe(text.length) + expect(input.selectionDirection).toBe("backward") + + dispose.fn?.() + }) + + it("shrinks a right-to-left shift selection across a mention", async () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const path = "README.md" + const text = `ی @${path} س` + const start = text.indexOf("@") + const end = start + path.length + 1 + mention.addPaths([path], "/repo") + + const input = textarea(text, end - 1, "rtl", { end: text.length, direction: "backward" }) + mention.snapSelection(input) + expect(input.selectionStart).toBe(start) + expect(input.selectionEnd).toBe(text.length) + + input.setSelectionRange(start + 1, text.length, "backward") + mention.snapSelection(input) + expect(input.selectionStart).toBe(end) + expect(input.selectionEnd).toBe(text.length) + expect(input.selectionDirection).toBe("backward") dispose.fn?.() }) diff --git a/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts index ae28f0b4171..3b406e9cdd9 100644 --- a/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts +++ b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts @@ -41,6 +41,59 @@ function setup() { } describe("useSpeechToText", () => { + it("waits for microphone readiness before reporting recording", () => { + const ctx = setup() + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + expect(ctx.speech.state()).toBe("starting") + expect(ctx.speech.active()).toBe(true) + + ctx.fire({ type: "speechToTextStarted", requestId: "another-request" }) + expect(ctx.speech.state()).toBe("starting") + + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) + expect(ctx.speech.state()).toBe("recording") + ctx.dispose() + }) + + it("does not stop or start another recording while the microphone is starting", () => { + const ctx = setup() + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.stop() + ctx.speech.start({ model: "other", insert: () => {} }) + expect(ctx.speech.state()).toBe("starting") + expect(ctx.sent).toEqual([start]) + + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) + ctx.speech.stop() + expect(ctx.speech.state()).toBe("transcribing") + expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId }) + ctx.dispose() + }) + + it("cancels a pending microphone startup and ignores its late acknowledgement", () => { + const ctx = setup() + + ctx.speech.start({ model: "scribe", insert: () => {} }) + const start = ctx.sent[0] + if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + + ctx.speech.cancel() + expect(ctx.sent[1]).toEqual({ type: "speechToTextCancel", requestId: start.requestId }) + expect(ctx.speech.state()).toBe("idle") + + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) + it("offers sign-in when stored credentials stop authenticating", () => { const ctx = setup() @@ -70,6 +123,7 @@ describe("useSpeechToText", () => { ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) }) const start = ctx.sent[0] if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) ctx.speech.stop({ done: () => done++ }) ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Recorded prompt" }) @@ -87,6 +141,7 @@ describe("useSpeechToText", () => { ctx.speech.start({ model: "scribe", insert: () => {} }) const start = ctx.sent[0] if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) ctx.speech.stop({ done: () => done++ }) ctx.speech.cancel() @@ -105,6 +160,7 @@ describe("useSpeechToText", () => { ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) }) const start = ctx.sent[0] if (start?.type !== "speechToTextStart") throw new Error("speech start message missing") + ctx.fire({ type: "speechToTextStarted", requestId: start.requestId }) ctx.speech.stop({ done: () => done++, ready: () => false }) ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Keep as draft" }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b76289fd805..b85a96390d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -80,10 +80,12 @@ import { DisplayProvider } from "../src/context/display" import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-models" import { NotificationsProvider } from "../src/context/notifications" import { FeedbackProvider } from "../src/context/feedback" +import { MemoryProvider } from "../src/context/memory" import { SessionProvider, useSession } from "../src/context/session" import { AgentRequirementsProvider } from "../src/context/agent-requirements" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" +import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" import { DataBridge, MermaidDownloadBridge } from "../src/App" @@ -3132,19 +3134,22 @@ export const AgentManagerApp: Component = () => { + - - - - - - - + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index d3f59a0d420..52639c60a30 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -468,6 +468,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran }} onPaste={(e) => imageAttach.handlePaste(e)} rows={3} + dir="auto" />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 4007ad8b9e1..173f3277092 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2709,10 +2709,11 @@ body.am-wt-dragging-active * { overflow-y: auto; } -.am-nv-dialog .am-prompt-input-container:has([class~="model-selector-popover"][data-component="popover-content"]), -.am-nv-dialog-content:has([class~="model-selector-popover"][data-component="popover-content"]), -[data-component="dialog"]:has(.am-nv-dialog [class~="model-selector-popover"][data-component="popover-content"]) - [data-slot="dialog-body"] { +/* While any inline (non-portaled) selector popover is open, let it escape the + dialog's scroll containers. Covers model, thinking-variant, and mode pickers. */ +.am-nv-dialog .am-prompt-input-container:has([data-component="popover-content"]), +.am-nv-dialog-content:has([data-component="popover-content"]), +[data-component="dialog"]:has(.am-nv-dialog [data-component="popover-content"]) [data-slot="dialog-body"] { overflow: visible; } diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index a60338c62c3..5ab642292e1 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -23,6 +23,7 @@ import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/dif import type { DiffViewerNotice } from "../src/types/messages/extension-messages" import { DiffPickerHeader } from "./DiffPickerHeader" import { BaseBranchPicker } from "./BaseBranchPicker" +import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" const NOTICE_KEYS: Record = { "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", @@ -305,6 +306,7 @@ export const DiffViewerApp: Component = () => { + diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index a4fbb410260..64e4b94eb37 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -20,12 +20,14 @@ import { DisplayProvider } from "./context/display" import { WorkStyleProvider } from "./context/work-style" import { IndexingProvider } from "./context/indexing" import { AgentRequirementsProvider } from "./context/agent-requirements" +import { MemoryProvider } from "./context/memory" import { SessionProvider, useSession } from "./context/session" import { LanguageBridge } from "./context/language-bridge" import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" +import { SpeechToTextPrewarm } from "./components/speech-to-text/SpeechToTextPrewarm" // Override the upstream "task" tool renderer with the fully-expanded version // that shows child session parts inline in the VS Code sidebar. @@ -409,6 +411,7 @@ const App: Component = () => { + @@ -416,11 +419,13 @@ const App: Component = () => { - - - - - + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index adb44b60718..b87ce38294b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -11,6 +11,7 @@ import { Component, For, Show, createMemo } from "solid-js" import { Dynamic } from "solid-js/web" import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part" import type { MessageFeedbackControls } from "@kilocode/kilo-ui/message-part" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, @@ -22,9 +23,11 @@ import { useSession } from "../../context/session" import { useDisplay } from "../../context/display" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" +import { useMemory } from "../../context/memory" import { useServer } from "../../context/server" import { snapshotProgress } from "../../context/session-utils" import { planDisplayPath } from "../../utils/plan-path" +import { MemoryMarkerMeta } from "@kilocode/kilo-memory/marker-meta" import { QuestionDock } from "./QuestionDock" import { SuggestBar } from "./SuggestBar" @@ -131,6 +134,8 @@ type ToolStateProps = { status?: string } +type MemoryItem = MemoryMarkerMeta.Decoded + function TodoToolCard(props: { part: ToolPart }) { const render = ToolRegistry.render(props.part.tool) const state = () => props.part.state as ToolStateProps @@ -183,6 +188,8 @@ export const AssistantMessage: Component = (props) => { const data = useData() const session = useSession() const display = useDisplay() + const mem = useMemory() + const language = useLanguage() const { config } = useConfig() const open = createMemo(() => config().terminal_command_display !== "collapsed") const edit = createMemo(() => config().code_edit_display === "expanded") @@ -198,6 +205,54 @@ export const AssistantMessage: Component = (props) => { return !!matchToolRequest(part, "question", session.questions()) }) }) + const meta = createMemo(() => + MemoryMarkerMeta.fromParts((props.parts ?? data.store.part?.[props.message.id] ?? []) as MemoryMarkerMeta.Part[]), + ) + const fmt = (value: number) => value.toLocaleString(language.locale()) + const count = (item: MemoryItem) => fmt(item.count) + const tokens = (item: MemoryItem) => fmt(item.tokens) + const label = (item: MemoryItem) => + item.type === "startup" ? language.t("chat.memory.badge.injected") : language.t("chat.memory.badge.recalled") + const detail = (item: MemoryItem) => + item.type === "startup" + ? language.t("chat.memory.badge.startupCtx") + : language.t("chat.memory.badge.items", { count: count(item) }) + const tip = (item: MemoryItem) => { + const err = mem.error() + if (err) return {err} + const status = mem.status() + if (!status) return {language.t("chat.memory.status.loading")} + const ops = status.state.stats.lastOperationCount + const total = mem.totalTokens().toLocaleString(language.locale()) + return ( +
+
+ {item.type === "startup" + ? language.t("chat.memory.session.tokens", { tokens: tokens(item) }) + : language.t("chat.memory.badge.recalledDetail", { count: count(item), tokens: tokens(item) })} +
+
{language.t("chat.memory.total.tokens", { tokens: total })}
+
+ {!status.state.enabled + ? language.t("chat.memory.project.disabled") + : language.t("chat.memory.project.enabled")} +
+ 0}> +
+ {language.t("chat.memory.savedOperations", { + count: ops.toLocaleString(language.locale()), + })} +
+
+ +
{mem.show()!.changes.split("\n").filter(Boolean).slice(-1)[0]}
+
+ 0}> +
{language.t("chat.memory.badge.files", { files: item.files.join(", ") })}
+
+
+ ) + } return ( <> @@ -290,6 +345,15 @@ export const AssistantMessage: Component = (props) => { ) }} + + {(item) => ( + +
+ {label(item())} · {detail(item())} · {language.t("chat.memory.badge.tokens", { tokens: tokens(item()) })} +
+
+ )} +
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx index b066d05982e..69a311a59a9 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ContextProgress.tsx @@ -13,12 +13,7 @@ import { Component, createMemo, Show } from "solid-js" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useSession } from "../../context/session" import { useProvider } from "../../context/provider" - -function fmt(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` - return String(n) -} +import { formatCompactCount as fmt } from "../../utils/format" export const ContextProgress: Component = () => { const session = useSession() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 5913ef4f366..a5c78cd5e1f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -157,6 +157,32 @@ export const MessageList: Component = (props) => { const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row]))) const keys = createMemo(() => partition().virtual.map((row) => row.key)) const fingerprint = createMemo(() => rowFingerprint(keys())) + + // Clicking a bar in the task timeline scrolls the transcript to that message. + // Jumps land instantly (no smooth animation): while pinned at the bottom, a + // smooth scroll's initial frames sit within createAutoScroll's near-bottom + // threshold, which resumes auto-follow mid-animation and snaps back down. + const onScrollToMessage = (e: Event) => { + const detail = (e as CustomEvent<{ id: string; partId?: string }>).detail + if (!detail?.id) return + const matches = rows().filter((r) => r.type === "assistant" && r.message.id === detail.id) + // Long messages split into multiple rows (chunks); land on the chunk that + // actually contains the clicked part, not just the message's first chunk. + const row = matches.find((r) => r.type === "assistant" && r.parts.some((p) => p.id === detail.partId)) ?? matches[0] + if (!row) return + autoScroll.pause() + const index = keys().indexOf(row.key) + if (index >= 0) { + virtualizer()?.scrollToIndex(index, { align: "start" }) + return + } + const el = scrollEl() + const target = el?.querySelector(`[data-row-key="${CSS.escape(row.key)}"]`) + target?.scrollIntoView({ block: "start" }) + } + window.addEventListener("scrollToMessage", onScrollToMessage) + onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage)) + const measurement = createMemo(() => { const id = session.currentSessionID() const token = layout() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index d9c346cb4b8..1593dadf0c2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -61,6 +61,8 @@ import { drafts, imageDrafts, reviewDrafts } from "../../utils/draft-store" import { ReviewComments } from "./ReviewComments" import { partReview, reviewBody } from "../../../../src/shared/review-comments" import { isEnterKeyCommitNotIme } from "../../utils/ime-enter" +import { MEMORY_USAGE, parseMemoryCommand } from "../../utils/memory-command" +import { useMemory } from "../../context/memory" const scrolls = new Map() function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] { @@ -91,6 +93,7 @@ export const PromptInput: Component = (props) => { const provider = useProvider() const language = useLanguage() const vscode = useVSCode() + const projectMemory = useMemory() const sid = () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID() ?? undefined const ctx = () => { const id = props.boxId @@ -936,9 +939,61 @@ export const PromptInput: Component = (props) => { transcribeAndSend() } + const runMemory = (memory: NonNullable>) => { + if (memory.kind === "usage") { + showToast({ variant: "error", title: language.t("chat.memory.command.failed"), description: memory.reason }) + return false + } + if (memory.kind === "help") { + showToast({ variant: "default", title: "/memory", description: MEMORY_USAGE }) + return true + } + if (isDisabled() || speech.active() || terminal.pending() || git.pending() || props.blocked?.()) return false + const status = projectMemory.status() + if ( + memory.kind === "operation" && + (memory.operation === "remember" || memory.operation === "correct" || memory.operation === "forget") && + status && + !status.state.enabled + ) { + showToast({ variant: "error", title: language.t("chat.memory.project.disabled") }) + return false + } + if (memory.kind === "show") vscode.postMessage({ type: "memoryShow", sessionID: sid() }) + if (memory.kind === "operation") { + vscode.postMessage({ + type: "memoryOperation", + operation: memory.operation, + sessionID: sid(), + ...(memory.operation === "auto" ? { mode: memory.mode } : {}), + ...(memory.operation === "purge" ? { confirm: memory.confirm } : {}), + ...(memory.operation === "remember" || memory.operation === "correct" ? { text: memory.text } : {}), + ...(memory.operation === "forget" ? { query: memory.query } : {}), + }) + } + return true + } + const handleSend = async () => { const draft = text().trim() + const memory = parseMemoryCommand(draft) + if (memory) { + if (!runMemory(memory)) return + history.append(draft) + setText("") + clearReviewComments() + imageAttach.clear() + mention.closeMention() + slash.close() + drafts.delete(draftKey()) + reviewDrafts.delete(draftKey()) + imageDrafts.delete(draftKey()) + scrolls.delete(draftKey()) + if (textareaRef) textareaRef.style.height = "auto" + return + } + // Detect slash command (hoisted for both client and server command checks). // Prioritize exact name matches over hint/alias matches so that a server // command named e.g. "continue" is not hijacked by a client alias. 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 96ac022f93f..468fe45d5ae 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -14,6 +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 { useMemory } from "../../context/memory" import { calcTokenUsage, collapseCostBreakdown } from "../../context/session-utils" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" @@ -24,6 +25,7 @@ import { hasModelUsage, tokenSummary } from "../../context/model-usage" import { SessionRenameEditor } from "../shared/SessionRenameEditor" import { target as todoTarget } from "../../context/todo-revert" import type { Part, TodoItem, ExtensionMessage } from "../../types/messages" +import { formatCompactCount } from "../../utils/format" interface TaskHeaderProps { readonly?: boolean @@ -31,6 +33,7 @@ interface TaskHeaderProps { export const TaskHeader: Component = (props) => { const session = useSession() + const memory = useMemory() const language = useLanguage() const title = createMemo(() => session.currentSession()?.title ?? language.t("command.session.new")) @@ -83,6 +86,17 @@ export const TaskHeader: Component = (props) => { return false }) + const memoryLabel = createMemo(() => { + const count = memory.sessionTokens() + return count > 0 + ? language.t("chat.memory.label", { tokens: formatCompactCount(count) }) + : language.t("chat.memory.on") + }) + + const memoryTooltip = createMemo(() => + language.t("chat.memory.session.tokens", { tokens: formatCompactCount(memory.sessionTokens()) }), + ) + const vscode = useVSCode() const [expanded, setExpanded] = createSignal(true) @@ -235,6 +249,69 @@ export const TaskHeader: Component = (props) => { {(tk) => } + +
+ + + + {memoryLabel()} + + + + + memory.showMemory()} + aria-label={language.t("chat.memory.inspect")} + /> + + + memory.remember()} + aria-label={language.t("chat.memory.remember")} + /> + + + memory.forget()} + aria-label={language.t("chat.memory.forget")} + /> + + + memory.rebuild()} + aria-label={language.t("chat.memory.rebuild")} + /> + + {/* Strip only mounts when enabled, so this is always the disable action; re-enable lives in Settings > Context. */} + + memory.disable()} + aria-label={language.t("chat.memory.disable")} + /> + + +
+
{/* Kilo Pass is part of personal credits, so only show it on the personal account */} - + {(pass) => (
= (props) => { {/* No active Kilo Pass on the personal account — nudge to subscribe */} - +
{ - const { config, updateConfig } = useConfig() + const { config, updateConfig, settings, updateSetting } = useConfig() const language = useLanguage() + const langValue = () => settings().languageCommitMessage ?? SYNC + const [expanded, setExpanded] = createSignal(Boolean(config().commit_message?.prompt)) const toggle = (checked: boolean) => { @@ -19,9 +27,46 @@ const CommitMessageTab: Component = () => { } } + const label = (opt: Option) => + opt === SYNC ? language.t("settings.commitMessage.language.sync") : LOCALE_LABELS[opt] + + const value = (opt: Option) => opt + + const onSelect = (opt: Option | undefined) => { + if (opt !== undefined) updateSetting("languageCommitMessage", opt) + } + + const currentLabel = createMemo(() => label(langValue() as Option)) + return ( -
- + +
+

+ {language.t("settings.commitMessage.language.description")} +

+